From ece02cd4ad60a3f65f8189b56c27dfde5ae4c58d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 28 Feb 2026 14:27:52 +0100 Subject: [PATCH] fix: isolate snapshot diff cache by locator scope + use Locator.selector() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 to a two-level WeakMap> 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. --- playwright | 2 +- playwriter/src/clean-html.ts | 3 +- playwriter/src/executor.ts | 14 ++- playwriter/src/locator-selector.test.ts | 115 ++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 playwriter/src/locator-selector.test.ts diff --git a/playwright b/playwright index dabe14a..7552ca1 160000 --- a/playwright +++ b/playwright @@ -1 +1 @@ -Subproject commit dabe14a38d904992e85e19eb98adf6d6225f8a3e +Subproject commit 7552ca14f15756a55a5ead5814e7b410e1273892 diff --git a/playwriter/src/clean-html.ts b/playwriter/src/clean-html.ts index 8bc922b..62f8e30 100644 --- a/playwriter/src/clean-html.ts +++ b/playwriter/src/clean-html.ts @@ -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 { diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 21b8ecb..40ca75b 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -251,7 +251,7 @@ export class PlaywrightExecutor { private userState: Record = {} private browserLogs: Map = new Map() - private lastSnapshots: WeakMap = new WeakMap() + private lastSnapshots: WeakMap> = new WeakMap() private lastRefToLocator: WeakMap> = 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 diff --git a/playwriter/src/locator-selector.test.ts b/playwriter/src/locator-selector.test.ts new file mode 100644 index 0000000..47a1768 --- /dev/null +++ b/playwriter/src/locator-selector.test.ts @@ -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 = ` + + +
+ + + + + + About Us +

Page Title

+

Some paragraph text

+
+ Card content +
+
+ Another card +
+
+ Third card +
+ + + Logo +
Dashboard
+
+ +` + +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""`) + }) +})