fix(browse): one ambiguous ref no longer kills the whole annotated screenshot

`snapshot -a` exits 1 with "Selector matched multiple elements" on most real
pages, so /qa, /canary and /land-and-deploy silently produce reports whose
screenshots do not exist. Plain `screenshot <path>` is unaffected.

Refs are built as getByRole(role, {name}) and disambiguated with .nth() when
role+name repeats. That disambiguation cannot fire for a node with NO accessible
name: the locator degrades to getByRole(role) with no name filter, and the count
driving .nth() is taken from the FILTERED aria snapshot while getByRole matches
the unfiltered DOM. Measured on a live page: the tree surfaced 2 unnamed
paragraphs, the DOM had 9. Landmarks (banner/main/contentinfo) and paragraphs are
correctly unnamed per ARIA, so this is the common case rather than an edge case.

boundingBox() then hits Playwright strict mode, and the catch allowlisted only
timeout/closed/Target/Execution-context messages — so the strict-mode error was
re-thrown and aborted every remaining annotation.

Two changes:

- `.first()` before boundingBox(), so an ambiguous ref draws a box on its first
  match instead of aborting. The heatmap path below has always tolerated this via
  a bare `catch {}`; annotate was the only path that could be killed outright.
- the catch no longer re-throws on unrecognised messages. A box we cannot measure
  is a box we do not draw, never a reason to lose the rest of the page. Set
  BROWSE_DEBUG to see what was skipped.

Also: `-o` passed without `-a`/`-H` was silently ignored (exit 0, no file), which
reads as "screenshots are broken" rather than "you forgot a flag". It now warns
and points at `browse screenshot <path>`.

Verified by rebuilding both ways against the same page with 51 refs present:
  before — "Selector matched multiple elements", no file written
  after  — exit 0, 229KB PNG

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Connex Client Access
2026-08-17 10:08:53 -07:00
committed by Garry Tan
co-authored by Claude Fable 5
parent ca671d6f65
commit 9c4de4fe5b
3 changed files with 104 additions and 3 deletions
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head><title>Ambiguous refs fixture</title></head>
<body>
<!-- "Save" is a substring of "Save As": getByRole('button', { name: 'Save' })
matches BOTH buttons (Playwright name matching is substring by default),
so the "Save" ref trips strict mode without .first(). This is the
deterministic form of the field failure in PR #2601. -->
<header>
<h1>Ambiguity test page</h1>
</header>
<main>
<p>Paragraph one of plain content.</p>
<p>Paragraph two of plain content.</p>
<button>Save</button>
<button>Save As</button>
<button>Cancel</button>
<a href="#end">Jump to end</a>
</main>
<footer>
<p id="end">Footer content.</p>
</footer>
</body>
</html>
+27
View File
@@ -321,6 +321,33 @@ describe('Annotated screenshots', () => {
fs.unlinkSync(screenshotPath);
});
// PR #2601 (@namtrok): one ambiguous ref must not kill the whole annotated
// screenshot. "Save" is a substring of "Save As", so the Save ref's locator
// matches two buttons — pre-fix, Playwright strict mode aborted every
// remaining annotation and no file was written.
test('snapshot -a survives ambiguous refs and reports them visibly (#2601)', async () => {
const screenshotPath = '/tmp/browse-test-annotated-ambiguous.png';
await handleWriteCommand('goto', [baseUrl + '/snapshot-ambiguous.html'], bm);
const result = await handleMetaCommand('snapshot', ['-a', '-o', screenshotPath], bm, shutdown);
// The screenshot landed despite the ambiguity...
expect(result).toContain('[annotated screenshot:');
expect(fs.existsSync(screenshotPath)).toBe(true);
expect(fs.statSync(screenshotPath).size).toBeGreaterThan(1000);
// ...refs after the ambiguous one are still in the snapshot...
expect(result).toContain('Save As');
expect(result).toContain('Cancel');
// ...and the first-match fallback is visible, never silent.
expect(result).toContain('ambiguous (first-match)');
fs.unlinkSync(screenshotPath);
});
test('snapshot -o without -a/-H warns instead of silently ignoring (#2601)', async () => {
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
const result = await handleMetaCommand('snapshot', ['-o', '/tmp/browse-test-ignored.png'], bm, shutdown);
expect(result).toContain('[warning] -o/--output was ignored');
expect(fs.existsSync('/tmp/browse-test-ignored.png')).toBe(false);
});
test('snapshot -a uses default path', async () => {
const defaultPath = '/tmp/browse-annotated.png';
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);