Skip to content

Commit 9044609

Browse files
fix: sort elements based on selector matching algorithm (#9836)
1 parent 8aea8e0 commit 9044609

File tree

3 files changed

+127
-14
lines changed

3 files changed

+127
-14
lines changed

packages/puppeteer-core/src/injected/PQuerySelector.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,67 @@ class PQueryEngine {
170170
}
171171
}
172172

173+
class DepthCalculator {
174+
#cache = new Map<Node, number[]>();
175+
176+
calculate(node: Node, depth: number[] = []): number[] {
177+
if (node instanceof Document) {
178+
return depth;
179+
}
180+
if (node instanceof ShadowRoot) {
181+
node = node.host;
182+
}
183+
184+
const cachedDepth = this.#cache.get(node);
185+
if (cachedDepth) {
186+
return [...cachedDepth, ...depth];
187+
}
188+
189+
let index = 0;
190+
for (
191+
let prevSibling = node.previousSibling;
192+
prevSibling;
193+
prevSibling = prevSibling.previousSibling
194+
) {
195+
++index;
196+
}
197+
198+
const value = this.calculate(node.parentNode as Node, [index]);
199+
this.#cache.set(node, value);
200+
return [...value, ...depth];
201+
}
202+
}
203+
204+
const compareDepths = (a: number[], b: number[]): -1 | 0 | 1 => {
205+
if (a.length + b.length === 0) {
206+
return 0;
207+
}
208+
const [i = Infinity, ...otherA] = a;
209+
const [j = Infinity, ...otherB] = b;
210+
if (i === j) {
211+
return compareDepths(otherA, otherB);
212+
}
213+
return i < j ? 1 : -1;
214+
};
215+
216+
const domSort = async function* (elements: AwaitableIterable<Node>) {
217+
const results = new Set<Node>();
218+
for await (const element of elements) {
219+
results.add(element);
220+
}
221+
const calculator = new DepthCalculator();
222+
yield* [...results.values()]
223+
.map(result => {
224+
return [result, calculator.calculate(result)] as const;
225+
})
226+
.sort(([, a], [, b]) => {
227+
return compareDepths(a, b);
228+
})
229+
.map(([result]) => {
230+
return result;
231+
});
232+
};
233+
173234
type QueryableNode = {
174235
querySelectorAll: typeof Document.prototype.querySelectorAll;
175236
};
@@ -179,7 +240,7 @@ type QueryableNode = {
179240
*
180241
* @internal
181242
*/
182-
export const pQuerySelectorAll = async function* (
243+
export const pQuerySelectorAll = function (
183244
root: Node,
184245
selector: string
185246
): AwaitableIterable<Node> {
@@ -195,10 +256,8 @@ export const pQuerySelectorAll = async function* (
195256
}
196257

197258
if (isPureCSS) {
198-
yield* (root as unknown as QueryableNode).querySelectorAll(selector);
199-
return;
259+
return (root as unknown as QueryableNode).querySelectorAll(selector);
200260
}
201-
202261
// If there are any empty elements, then this implies the selector has
203262
// contiguous combinators (e.g. `>>> >>>>`) or starts/ends with one which we
204263
// treat as illegal, similar to existing behavior.
@@ -221,11 +280,13 @@ export const pQuerySelectorAll = async function* (
221280
);
222281
}
223282

224-
for (const selectorParts of selectors) {
225-
const query = new PQueryEngine(root, selector, selectorParts);
226-
query.run();
227-
yield* query.elements;
228-
}
283+
return domSort(
284+
AsyncIterableUtil.flatMap(selectors, selectorParts => {
285+
const query = new PQueryEngine(root, selector, selectorParts);
286+
query.run();
287+
return query.elements;
288+
})
289+
);
229290
};
230291

231292
/**

packages/puppeteer-core/src/util/AsyncIterableUtil.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ export class AsyncIterableUtil {
2828
}
2929
}
3030

31-
static async *flatMap<T>(
31+
static async *flatMap<T, U>(
3232
iterable: AwaitableIterable<T>,
33-
map: (item: T) => AwaitableIterable<T>
34-
): AsyncIterable<T> {
33+
map: (item: T) => AwaitableIterable<U>
34+
): AsyncIterable<U> {
3535
for await (const value of iterable) {
3636
yield* map(value);
3737
}

test/src/queryhandler.spec.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,9 @@ describe('Query handler tests', function () {
359359
describe('P selectors', () => {
360360
beforeEach(async () => {
361361
const {page} = getTestState();
362-
await page.setContent('<div>hello <button>world</button></div>');
362+
await page.setContent(
363+
'<div>hello <button>world<span></span></button></div>'
364+
);
363365
Puppeteer.clearCustomQueryHandlers();
364366
});
365367

@@ -489,10 +491,60 @@ describe('Query handler tests', function () {
489491
expect(value).toMatchObject({textContent: 'world', tagName: 'BUTTON'});
490492
});
491493

492-
it('should work with commas', async () => {
494+
it('should work with selector lists', async () => {
493495
const {page} = getTestState();
494496
const elements = await page.$$('div, ::-p-text(world)');
495497
expect(elements.length).toStrictEqual(2);
496498
});
499+
500+
const permute = <T>(inputs: T[]): T[][] => {
501+
const results: T[][] = [];
502+
for (let i = 0; i < inputs.length; ++i) {
503+
const permutation = permute(
504+
inputs.slice(0, i).concat(inputs.slice(i + 1))
505+
);
506+
const value = inputs[i] as T;
507+
if (permutation.length === 0) {
508+
results.push([value]);
509+
continue;
510+
}
511+
for (const part of permutation) {
512+
results.push([value].concat(part));
513+
}
514+
}
515+
return results;
516+
};
517+
518+
it('should match querySelector* ordering', async () => {
519+
const {page} = getTestState();
520+
for (const list of permute(['div', 'button', 'span'])) {
521+
const expected = await page.evaluate(selector => {
522+
return [...document.querySelectorAll(selector)].map(element => {
523+
return element.tagName;
524+
});
525+
}, list.join(','));
526+
const elements = await page.$$(
527+
list
528+
.map(selector => {
529+
return selector === 'button' ? '::-p-text(world)' : selector;
530+
})
531+
.join(',')
532+
);
533+
const actual = await Promise.all(
534+
elements.map(element => {
535+
return element.evaluate(element => {
536+
return element.tagName;
537+
});
538+
})
539+
);
540+
expect(actual.join()).toStrictEqual(expected.join());
541+
}
542+
});
543+
544+
it('should not have duplicate elements from selector lists', async () => {
545+
const {page} = getTestState();
546+
const elements = await page.$$('::-p-text(world), button');
547+
expect(elements.length).toStrictEqual(1);
548+
});
497549
});
498550
});

0 commit comments

Comments
 (0)