From 32715b8776d244413b6c140f2d04cb42950bf9a3 Mon Sep 17 00:00:00 2001 From: "Abnoz.v2" Date: Sun, 20 Sep 2026 11:36:48 +0100 Subject: [PATCH 1/3] fix(selectors): reset regex state before matching each element Text and label matchers reuse one RegExp across elements. With the g or y flag, RegExp.prototype.test keeps lastIndex between calls, so every other matching element was skipped. Fixes https://github.com/microsoft/playwright/issues/42813 --- packages/injected/src/injectedScript.ts | 12 ++++++++++-- packages/injected/src/selectorEvaluator.ts | 6 +++++- tests/page/selectors-get-by.spec.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 85963d3a37696..ca0d06e63ec91 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1750,7 +1750,12 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text if (selector[0] === '/' && selector.lastIndexOf('/') > 0) { const lastSlash = selector.lastIndexOf('/'); const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1)); - return { matcher: (elementText: ElementText) => re.test(elementText.full), kind: 'regex' }; + const matcher = (elementText: ElementText) => { + // Global and sticky regexes keep lastIndex between calls. + re.lastIndex = 0; + return re.test(elementText.full); + }; + return { matcher, kind: 'regex' }; } const unquote = internal ? JSON.parse.bind(JSON) : cssUnquote; let strict = false; @@ -1812,8 +1817,11 @@ class ExpectedTextMatcher { return text === this._string; if (this._substring !== undefined) return text.includes(this._substring); - if (this._regex) + if (this._regex) { + // Global and sticky regexes keep lastIndex between calls. + this._regex.lastIndex = 0; return !!this._regex.test(text); + } return false; } diff --git a/packages/injected/src/selectorEvaluator.ts b/packages/injected/src/selectorEvaluator.ts index c02aa73c147e3..079517b92bb03 100644 --- a/packages/injected/src/selectorEvaluator.ts +++ b/packages/injected/src/selectorEvaluator.ts @@ -488,7 +488,11 @@ const textMatchesEngine: SelectorEngine = { if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string')) throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined); - const matcher = (elementText: ElementText) => re.test(elementText.full); + const matcher = (elementText: ElementText) => { + // Global and sticky regexes keep lastIndex between calls. + re.lastIndex = 0; + return re.test(elementText.full); + }; return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts index de76065eca6c0..f9f8a94c1423c 100644 --- a/tests/page/selectors-get-by.spec.ts +++ b/tests/page/selectors-get-by.spec.ts @@ -375,3 +375,15 @@ it('getByRole with description whitespace normalization', async ({ page }) => { 'Alert', ]); }); + +it('getByText and getByLabel should not be affected by global and sticky regex flags', async ({ page }) => { + await page.setContent(` +
foo
foo
foo
+ + `); + await expect(page.getByText(/foo/)).toHaveCount(6); + await expect(page.getByText(/foo/g)).toHaveCount(6); + await expect(page.getByText(/foo/y)).toHaveCount(6); + await expect(page.getByLabel(/foo/)).toHaveCount(3); + await expect(page.getByLabel(/foo/g)).toHaveCount(3); +}); From 5cf6fee14fc41f030abb918823b835c6f815145a Mon Sep 17 00:00:00 2001 From: "Abnoz.v2" Date: Tue, 22 Sep 2026 12:34:48 +0100 Subject: [PATCH 2/3] fix(selectors): reset sticky regex state in attribute matchers too String.prototype.match resets lastIndex for the g flag but not for y, so getByRole name matching and attribute-based locators were still stateful across elements. --- packages/injected/src/injectedScript.ts | 9 +++++++-- packages/injected/src/selectorUtils.ts | 5 ++++- tests/page/selectors-get-by.spec.ts | 17 +++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index ca0d06e63ec91..8a930d444b714 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1723,8 +1723,13 @@ function oneLine(s: string): string { function createAttributeMatcher(part: AttributeSelectorPart): (s: string) => boolean { const { value, caseSensitive } = part; - if (value instanceof RegExp) - return s => !!s.match(value); + if (value instanceof RegExp) { + return s => { + // Sticky regexes keep lastIndex between calls. + value.lastIndex = 0; + return !!s.match(value); + }; + } if (caseSensitive) return s => s === value; const lowerCaseValue = value.toLowerCase(); diff --git a/packages/injected/src/selectorUtils.ts b/packages/injected/src/selectorUtils.ts index 89ef0a1c90277..e7d89ec2e1225 100644 --- a/packages/injected/src/selectorUtils.ts +++ b/packages/injected/src/selectorUtils.ts @@ -35,8 +35,11 @@ export function matchesAttributePart(value: any, attr: AttributeSelectorPart) { if (attr.op === '') return !!objValue; if (attr.op === '=') { - if (attrValue instanceof RegExp) + if (attrValue instanceof RegExp) { + // Sticky regexes keep lastIndex between calls. + attrValue.lastIndex = 0; return typeof objValue === 'string' && !!objValue.match(attrValue); + } return objValue === attrValue; } if (typeof objValue !== 'string' || typeof attrValue !== 'string') diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts index f9f8a94c1423c..1eca1ec3088aa 100644 --- a/tests/page/selectors-get-by.spec.ts +++ b/tests/page/selectors-get-by.spec.ts @@ -376,14 +376,23 @@ it('getByRole with description whitespace normalization', async ({ page }) => { ]); }); -it('getByText and getByLabel should not be affected by global and sticky regex flags', async ({ page }) => { +it('locators should not be affected by global and sticky regex flags', async ({ page }) => { await page.setContent(`
foo
foo
foo
+ + `); - await expect(page.getByText(/foo/)).toHaveCount(6); - await expect(page.getByText(/foo/g)).toHaveCount(6); - await expect(page.getByText(/foo/y)).toHaveCount(6); + await expect(page.getByText(/foo/)).toHaveCount(9); + await expect(page.getByText(/foo/g)).toHaveCount(9); + await expect(page.getByText(/foo/y)).toHaveCount(9); await expect(page.getByLabel(/foo/)).toHaveCount(3); await expect(page.getByLabel(/foo/g)).toHaveCount(3); + await expect(page.getByLabel(/foo/y)).toHaveCount(3); + await expect(page.getByRole('button', { name: /foo/ })).toHaveCount(3); + await expect(page.getByRole('button', { name: /foo/g })).toHaveCount(3); + await expect(page.getByRole('button', { name: /foo/y })).toHaveCount(3); + await expect(page.getByPlaceholder(/foo/)).toHaveCount(3); + await expect(page.getByPlaceholder(/foo/g)).toHaveCount(3); + await expect(page.getByPlaceholder(/foo/y)).toHaveCount(3); }); From d2a88332af94524f791e36772b4a05d5f41a430c Mon Sep 17 00:00:00 2001 From: "Abnoz.v2" Date: Tue, 22 Sep 2026 16:36:07 +0100 Subject: [PATCH 3/3] chore(selectors): drop explanatory comments per review --- packages/injected/src/injectedScript.ts | 3 --- packages/injected/src/selectorEvaluator.ts | 1 - packages/injected/src/selectorUtils.ts | 1 - 3 files changed, 5 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 8a930d444b714..511c09a329701 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1725,7 +1725,6 @@ function createAttributeMatcher(part: AttributeSelectorPart): (s: string) => boo const { value, caseSensitive } = part; if (value instanceof RegExp) { return s => { - // Sticky regexes keep lastIndex between calls. value.lastIndex = 0; return !!s.match(value); }; @@ -1756,7 +1755,6 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text const lastSlash = selector.lastIndexOf('/'); const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1)); const matcher = (elementText: ElementText) => { - // Global and sticky regexes keep lastIndex between calls. re.lastIndex = 0; return re.test(elementText.full); }; @@ -1823,7 +1821,6 @@ class ExpectedTextMatcher { if (this._substring !== undefined) return text.includes(this._substring); if (this._regex) { - // Global and sticky regexes keep lastIndex between calls. this._regex.lastIndex = 0; return !!this._regex.test(text); } diff --git a/packages/injected/src/selectorEvaluator.ts b/packages/injected/src/selectorEvaluator.ts index 079517b92bb03..14cb489043b3a 100644 --- a/packages/injected/src/selectorEvaluator.ts +++ b/packages/injected/src/selectorEvaluator.ts @@ -489,7 +489,6 @@ const textMatchesEngine: SelectorEngine = { throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined); const matcher = (elementText: ElementText) => { - // Global and sticky regexes keep lastIndex between calls. re.lastIndex = 0; return re.test(elementText.full); }; diff --git a/packages/injected/src/selectorUtils.ts b/packages/injected/src/selectorUtils.ts index e7d89ec2e1225..b1fea88b33577 100644 --- a/packages/injected/src/selectorUtils.ts +++ b/packages/injected/src/selectorUtils.ts @@ -36,7 +36,6 @@ export function matchesAttributePart(value: any, attr: AttributeSelectorPart) { return !!objValue; if (attr.op === '=') { if (attrValue instanceof RegExp) { - // Sticky regexes keep lastIndex between calls. attrValue.lastIndex = 0; return typeof objValue === 'string' && !!objValue.match(attrValue); }