fix: isolate snapshot diff cache by locator scope + use Locator.selector()

The snapshot diff cache in executor.ts used only the Page object as key, so
calling snapshot({ page }) then snapshot({ page, locator }) would diff a
locator-scoped subtree against the previous full-page snapshot — producing
misleading diffs full of 'removed' lines that are simply outside the scope.

Fix: change lastSnapshots from WeakMap<Page, string> to a two-level
WeakMap<Page, Map<string, string>> keyed by locator.selector() (or '__page__'
for full-page). This matches the pattern already used by getCleanHTML in
clean-html.ts.

Also replaced (locator as any)._selector with the new typed
locator.selector() API from @xmorse/playwright-core 1.59.5 in both
executor.ts and clean-html.ts — zero `as any` casts.

Added locator-selector.test.ts with 28 inline snapshots covering CSS
selectors, getByRole, getByText, getByPlaceholder, getByLabel, getByAltText,
getByTestId, chained/filtered/described locators, and and/or combinators.
This commit is contained in:
Tommy D. Rossi
2026-02-28 14:27:52 +01:00
parent bf319088ab
commit ece02cd4ad
4 changed files with 128 additions and 6 deletions
+1 -2
View File
@@ -28,8 +28,7 @@ function getSnapshotKey(locator: Locator | Page): string {
if (isPage(locator)) {
return '__page__'
}
// For locators, use a string representation
return (locator as any)._selector || '__locator__'
return locator.selector()
}
export async function getCleanHTML(options: GetCleanHTMLOptions): Promise<string> {
+11 -3
View File
@@ -251,7 +251,7 @@ export class PlaywrightExecutor {
private userState: Record<string, any> = {}
private browserLogs: Map<string, string[]> = new Map()
private lastSnapshots: WeakMap<Page, string> = new WeakMap()
private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
private warningEvents: WarningEvent[] = []
private nextWarningEventId = 0
@@ -788,9 +788,17 @@ export class PlaywrightExecutor {
this.lastRefToLocator.set(resolvedPage, refToLocator)
const shouldCacheSnapshot = !frame
const previousSnapshot = shouldCacheSnapshot ? this.lastSnapshots.get(resolvedPage) : undefined
// Cache keyed by locator selector so full-page and locator-scoped snapshots
// don't pollute each other's diff baselines
const snapshotKey = locator ? locator.selector() : '__page__'
let pageSnapshots = this.lastSnapshots.get(resolvedPage)
if (!pageSnapshots) {
pageSnapshots = new Map()
this.lastSnapshots.set(resolvedPage, pageSnapshots)
}
const previousSnapshot = shouldCacheSnapshot ? pageSnapshots.get(snapshotKey) : undefined
if (shouldCacheSnapshot) {
this.lastSnapshots.set(resolvedPage, snapshotStr)
pageSnapshots.set(snapshotKey, snapshotStr)
}
// Diff defaults off when search is provided, but agent can explicitly enable both
+115
View File
@@ -0,0 +1,115 @@
// Tests for Locator.selector() — verifies the raw selector strings returned
// by various locator creation methods (getByRole, locator, getByText, etc.)
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { chromium, type Page, type Browser } from '@xmorse/playwright-core'
const HTML = `<!DOCTYPE html>
<html>
<body>
<div id="main">
<button>Submit</button>
<button>Cancel</button>
<button class="primary">Save</button>
<input type="text" placeholder="Enter name" />
<input type="email" placeholder="Enter email" />
<a href="/about">About Us</a>
<h1>Page Title</h1>
<p>Some paragraph text</p>
<div class="card">
<span>Card content</span>
</div>
<div class="card">
<span>Another card</span>
</div>
<div class="card">
<span>Third card</span>
</div>
<label for="age">Age</label>
<input id="age" type="number" />
<img alt="Logo" src="/logo.png" />
<div data-testid="dashboard">Dashboard</div>
</div>
</body>
</html>`
describe('Locator.selector()', () => {
let browser: Browser
let page: Page
beforeAll(async () => {
browser = await chromium.launch({ headless: true })
const context = await browser.newContext()
page = await context.newPage()
await page.setContent(HTML)
})
afterAll(async () => {
await browser.close()
})
it('CSS selectors', () => {
expect(page.locator('#main').selector()).toMatchInlineSnapshot(`"#main"`)
expect(page.locator('.card').selector()).toMatchInlineSnapshot(`".card"`)
expect(page.locator('button').selector()).toMatchInlineSnapshot(`"button"`)
expect(page.locator('div.card > span').selector()).toMatchInlineSnapshot(`"div.card > span"`)
expect(page.locator('#main .card:first-child').selector()).toMatchInlineSnapshot(`"#main .card:first-child"`)
})
it('getByRole', () => {
expect(page.getByRole('button').selector()).toMatchInlineSnapshot(`"internal:role=button"`)
expect(page.getByRole('button', { name: 'Submit' }).selector()).toMatchInlineSnapshot(`"internal:role=button[name="Submit"i]"`)
expect(page.getByRole('link').selector()).toMatchInlineSnapshot(`"internal:role=link"`)
expect(page.getByRole('heading').selector()).toMatchInlineSnapshot(`"internal:role=heading"`)
expect(page.getByRole('textbox').selector()).toMatchInlineSnapshot(`"internal:role=textbox"`)
})
it('getByText', () => {
expect(page.getByText('Submit').selector()).toMatchInlineSnapshot(`"internal:text="Submit"i"`)
expect(page.getByText('Some paragraph').selector()).toMatchInlineSnapshot(`"internal:text="Some paragraph"i"`)
expect(page.getByText(/card/i).selector()).toMatchInlineSnapshot(`"internal:text=/card/i"`)
})
it('getByPlaceholder', () => {
expect(page.getByPlaceholder('Enter name').selector()).toMatchInlineSnapshot(`"internal:attr=[placeholder="Enter name"i]"`)
expect(page.getByPlaceholder('Enter email').selector()).toMatchInlineSnapshot(`"internal:attr=[placeholder="Enter email"i]"`)
})
it('getByLabel', () => {
expect(page.getByLabel('Age').selector()).toMatchInlineSnapshot(`"internal:label="Age"i"`)
})
it('getByAltText', () => {
expect(page.getByAltText('Logo').selector()).toMatchInlineSnapshot(`"internal:attr=[alt="Logo"i]"`)
})
it('getByTestId', () => {
expect(page.getByTestId('dashboard').selector()).toMatchInlineSnapshot(`"internal:testid=[data-testid="dashboard"s]"`)
})
it('chained locators', () => {
expect(page.locator('#main').locator('.card').selector()).toMatchInlineSnapshot(`"#main >> .card"`)
expect(page.locator('.card').first().selector()).toMatchInlineSnapshot(`".card >> nth=0"`)
expect(page.locator('.card').last().selector()).toMatchInlineSnapshot(`".card >> nth=-1"`)
expect(page.locator('.card').nth(1).selector()).toMatchInlineSnapshot(`".card >> nth=1"`)
})
it('filtered locators', () => {
expect(page.locator('button').filter({ hasText: 'Save' }).selector()).toMatchInlineSnapshot(`"button >> internal:has-text="Save"i"`)
expect(page.locator('div').filter({ has: page.locator('span') }).selector()).toMatchInlineSnapshot(`"div >> internal:has="span""`)
expect(page.locator('button').filter({ hasNotText: 'Cancel' }).selector()).toMatchInlineSnapshot(`"button >> internal:has-not-text="Cancel"i"`)
})
it('described locators', () => {
expect(page.locator('button').describe('main action button').selector()).toMatchInlineSnapshot(`"button >> internal:describe="main action button""`)
})
it('combined with and/or', () => {
expect(
page.locator('button').and(page.locator('.primary')).selector(),
).toMatchInlineSnapshot(`"button >> internal:and=".primary""`)
expect(
page.locator('button').or(page.locator('a')).selector(),
).toMatchInlineSnapshot(`"button >> internal:or="a""`)
})
})