diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 24d0b4f699..cbc6bdb313 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -63,11 +63,6 @@ export interface SelectorEngineV2 { queryAll(root: SelectorRoot, body: any): Element[]; } -export type ElementMatch = { - element: Element; - capture: Element | undefined; -}; - export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; }; @@ -105,7 +100,7 @@ export class InjectedScript { this._engines.set('data-test:light', this._createAttributeEngine('data-test', false)); this._engines.set('css', this._createCSSEngine()); this._engines.set('nth', { queryAll: () => [] }); - this._engines.set('visible', { queryAll: () => [] }); + this._engines.set('visible', this._createVisibleEngine()); this._engines.set('control', this._createControlEngine()); this._engines.set('has', this._createHasEngine()); @@ -140,93 +135,70 @@ export class InjectedScript { } querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined { - if (!(root as any)['querySelector']) - throw this.createStacklessError('Node is not queryable.'); - this._evaluator.begin(); - try { - const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map()); - if (strict && result.length > 1) - throw this.strictModeViolationError(selector, result.map(r => r.element)); - return result[0]?.capture || result[0]?.element; - } finally { - this._evaluator.end(); - } + const result = this.querySelectorAll(selector, root); + if (strict && result.length > 1) + throw this.strictModeViolationError(selector, result); + return result[0]; } - private _querySelectorRecursively(roots: ElementMatch[], selector: ParsedSelector, index: number, queryCache: Map): ElementMatch[] { - if (index === selector.parts.length) - return roots; - - const part = selector.parts[index]; - if (part.name === 'nth') { - let filtered: ElementMatch[] = []; - if (part.body === '0') { - filtered = roots.slice(0, 1); - } else if (part.body === '-1') { - if (roots.length) - filtered = roots.slice(roots.length - 1); - } else { - if (typeof selector.capture === 'number') - throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); - const nth = +part.body; - const set = new Set(); - for (const root of roots) { - set.add(root.element); - if (nth + 1 === set.size) - filtered = [root]; - } - } - return this._querySelectorRecursively(filtered, selector, index + 1, queryCache); - } - - if (part.name === 'visible') { - const visible = Boolean(part.body); - const filtered = roots.filter(match => visible === isVisible(match.element)); - return this._querySelectorRecursively(filtered, selector, index + 1, queryCache); - } - - const result: ElementMatch[] = []; - for (const root of roots) { - const capture = index - 1 === selector.capture ? root.element : root.capture; - - // Do not query engine twice for the same element. - let queryResults = queryCache.get(root.element); - if (!queryResults) { - queryResults = []; - queryCache.set(root.element, queryResults); - } - let all = queryResults[index]; - if (!all) { - all = this._queryEngineAll(part, root.element); - queryResults[index] = all; - } - - for (const element of all) { - if (!('nodeName' in element)) - throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`); - result.push({ element, capture }); - } - } - return this._querySelectorRecursively(result, selector, index + 1, queryCache); + private _queryNth(roots: Set, part: ParsedSelectorPart): Set { + const list = [...roots]; + let nth = +part.body; + if (nth === -1) + nth = list.length - 1; + return new Set(list.slice(nth, nth + 1)); } querySelectorAll(selector: ParsedSelector, root: Node): Element[] { + if (selector.capture !== undefined) { + if (selector.parts.some(part => part.name === 'nth')) + throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); + const withHas: ParsedSelector = { parts: selector.parts.slice(0, selector.capture + 1) }; + if (selector.capture < selector.parts.length - 1) { + const body = { parts: selector.parts.slice(selector.capture + 1) }; + const has: ParsedSelectorPart = { name: 'has', body, source: stringifySelector(body) }; + withHas.parts.push(has); + } + return this.querySelectorAll(withHas, root); + } + if (!(root as any)['querySelectorAll']) throw this.createStacklessError('Node is not queryable.'); + + if (selector.capture !== undefined) { + // We should have handled the capture above. + throw this.createStacklessError('Internal error: there should not be a capture in the selector.'); + } + this._evaluator.begin(); try { - const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map()); - const set = new Set(); - for (const r of result) - set.add(r.capture || r.element); - return [...set]; + let roots = new Set([root as Element]); + for (const part of selector.parts) { + if (part.name === 'nth') { + roots = this._queryNth(roots, part); + } else { + const next = new Set(); + for (const root of roots) { + const all = this._queryEngineAll(part, root); + for (const one of all) + next.add(one); + } + roots = next; + } + } + return [...roots]; } finally { this._evaluator.end(); } } private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] { - return this._engines.get(part.name)!.queryAll(root, part.body); + const result = this._engines.get(part.name)!.queryAll(root, part.body); + for (const element of result) { + if (!('nodeName' in element)) + throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`); + } + return result; } private _createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine { @@ -304,6 +276,15 @@ export class InjectedScript { return { queryAll }; } + private _createVisibleEngine(): SelectorEngineV2 { + const queryAll = (root: SelectorRoot, body: string) => { + if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) + return []; + return isVisible(root as Element) === Boolean(body) ? [root as Element] : []; + }; + return { queryAll }; + } + extend(source: string, params: any): any { const constrFunction = globalThis.eval(` (() => { diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index f82c6c5c32..fbd13c5190 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -135,6 +135,31 @@ it('should work with nth=', async ({ page }) => { }); const element = await promise; expect(await element.evaluate(e => e.id)).toBe('target3'); + + await page.setContent(` +
+
+
+ hi + hello +
+
+
+ `); + expect(await page.locator('div >> div >> span >> nth=1').textContent()).toBe('hello'); +}); + +it('should work with strict mode and chaining', async ({ page }) => { + await page.setContent(` +
+
+
+ hi +
+
+
+ `); + expect(await page.locator('div >> div >> span').textContent()).toBe('hi'); }); it('should work with position selectors', async ({ page }) => { @@ -373,3 +398,35 @@ it('should work with has=', async ({ page, server }) => { const error4 = await page.$(`div >> has="span!"`).catch(e => e); expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"'); }); + +it('chaining should work with large DOM @smoke', async ({ page, server }) => { + await page.evaluate(() => { + let last = document.body; + for (let i = 0; i < 100; i++) { + const e = document.createElement('div'); + last.appendChild(e); + last = e; + } + const target = document.createElement('span'); + target.textContent = 'Found me!'; + last.appendChild(target); + }); + + // Naive implementation generates C(100, 9) ~= 1.9*10^12 entries. + const selectors = [ + 'div >> div >> div >> div >> div >> div >> div >> div >> span', + 'div div div div div div div div span', + 'div div >> div div >> div div >> div div >> span', + ]; + + const counts = []; + const times = []; + for (const selector of selectors) { + const time = Date.now(); + counts.push(await page.$$eval(selector, els => els.length)); + times.push({ selector, time: Date.now() - time }); + } + expect(counts).toEqual([1, 1, 1]); + // Uncomment to see performance results. + // console.log(times); +});