fix(textContent): make page.textContent(selector) atomic (#2717)

We now query selector and take textContent synchronously. This
avoids any issues with async processing: node being recycled,
detached, etc.

More methods will follow with the same atomic pattern.

Drive-by: fixed selector engine names being sometimes case-sensitive
and sometimes not.
This commit is contained in:
Dmitry Gozman 2020-06-26 16:32:42 -07:00 committed by GitHub
parent 43f70ab978
commit b54303a386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 163 additions and 98 deletions

View File

@ -52,7 +52,6 @@ export function parseSelector(selector: string): ParsedSelector {
name = 'css'; name = 'css';
body = part; body = part;
} }
name = name.toLowerCase();
let capture = false; let capture = false;
if (name[0] === '*') { if (name[0] === '*') {
capture = true; capture = true;

View File

@ -25,7 +25,7 @@ import * as injectedScriptSource from './generated/injectedScriptSource';
import * as debugScriptSource from './generated/debugScriptSource'; import * as debugScriptSource from './generated/debugScriptSource';
import * as js from './javascript'; import * as js from './javascript';
import { Page } from './page'; import { Page } from './page';
import { selectors } from './selectors'; import { selectors, SelectorInfo } from './selectors';
import * as types from './types'; import * as types from './types';
import { Progress } from './progress'; import { Progress } from './progress';
import DebugScript from './debug/injected/debugScript'; import DebugScript from './debug/injected/debugScript';
@ -790,3 +790,55 @@ function roundPoint(point: types.Point): types.Point {
y: (point.y * 100 | 0) / 100, y: (point.y * 100 | 0) / 100,
}; };
} }
export type SchedulableTask<T> = (injectedScript: js.JSHandle<InjectedScript>) => Promise<js.JSHandle<types.InjectedScriptPoll<T>>>;
export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden'): SchedulableTask<Element | undefined> {
return injectedScript => injectedScript.evaluateHandle((injected, { parsed, state }) => {
let lastElement: Element | undefined;
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
const visible = element ? injected.isVisible(element) : false;
if (lastElement !== element) {
lastElement = element;
if (!element)
progress.log(` selector did not resolve to any element`);
else
progress.log(` selector resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`);
}
switch (state) {
case 'attached':
return element ? element : continuePolling;
case 'detached':
return !element ? undefined : continuePolling;
case 'visible':
return visible ? element : continuePolling;
case 'hidden':
return !visible ? undefined : continuePolling;
}
});
}, { parsed: selector.parsed, state });
}
export function dispatchEventTask(selector: SelectorInfo, type: string, eventInit: Object): SchedulableTask<undefined> {
return injectedScript => injectedScript.evaluateHandle((injected, { parsed, type, eventInit }) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (element)
injected.dispatchEvent(element, type, eventInit);
return element ? undefined : continuePolling;
});
}, { parsed: selector.parsed, type, eventInit });
}
export function textContentTask(selector: SelectorInfo): SchedulableTask<string | null> {
return injectedScript => injectedScript.evaluateHandle((injected, parsed) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
return element ? element.textContent : continuePolling;
});
}, selector.parsed);
}

View File

@ -33,7 +33,7 @@ type ContextData = {
contextPromise: Promise<dom.FrameExecutionContext>; contextPromise: Promise<dom.FrameExecutionContext>;
contextResolveCallback: (c: dom.FrameExecutionContext) => void; contextResolveCallback: (c: dom.FrameExecutionContext) => void;
context: dom.FrameExecutionContext | null; context: dom.FrameExecutionContext | null;
rerunnableTasks: Set<RerunnableTask<any>>; rerunnableTasks: Set<RerunnableTask>;
}; };
export type GotoResult = { export type GotoResult = {
@ -456,10 +456,10 @@ export class Frame {
if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`Unsupported state option "${state}"`); throw new Error(`Unsupported state option "${state}"`);
const info = selectors._parseSelector(selector); const info = selectors._parseSelector(selector);
const task = selectors._waitForSelectorTask(info, state); const task = dom.waitForSelectorTask(info, state);
return this._page._runAbortableTask(async progress => { return this._page._runAbortableTask(async progress => {
progress.logger.info(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); progress.logger.info(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
const result = await this._scheduleRerunnableTask(progress, info.world, task); const result = await this._scheduleRerunnableHandleTask(progress, info.world, task);
if (!result.asElement()) { if (!result.asElement()) {
result.dispose(); result.dispose();
return null; return null;
@ -477,11 +477,11 @@ export class Frame {
async dispatchEvent(selector: string, type: string, eventInit?: Object, options: types.TimeoutOptions = {}): Promise<void> { async dispatchEvent(selector: string, type: string, eventInit?: Object, options: types.TimeoutOptions = {}): Promise<void> {
const info = selectors._parseSelector(selector); const info = selectors._parseSelector(selector);
const task = selectors._dispatchEventTask(info, type, eventInit || {}); const task = dom.dispatchEventTask(info, type, eventInit || {});
return this._page._runAbortableTask(async progress => { return this._page._runAbortableTask(async progress => {
progress.logger.info(`Dispatching "${type}" event on selector "${selector}"...`); progress.logger.info(`Dispatching "${type}" event on selector "${selector}"...`);
const result = await this._scheduleRerunnableTask(progress, 'main', task); // Note: we always dispatch events in the main world.
result.dispose(); await this._scheduleRerunnableTask(progress, 'main', task);
}, this._page._timeoutSettings.timeout(options), this._apiName('dispatchEvent')); }, this._page._timeoutSettings.timeout(options), this._apiName('dispatchEvent'));
} }
@ -723,8 +723,8 @@ export class Frame {
return this._page._runAbortableTask(async progress => { return this._page._runAbortableTask(async progress => {
while (progress.isRunning()) { while (progress.isRunning()) {
progress.logger.info(`waiting for selector "${selector}"`); progress.logger.info(`waiting for selector "${selector}"`);
const task = selectors._waitForSelectorTask(info, 'attached'); const task = dom.waitForSelectorTask(info, 'attached');
const handle = await this._scheduleRerunnableTask(progress, info.world, task); const handle = await this._scheduleRerunnableHandleTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>; const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => element.dispose()); progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element); const result = await action(progress, element);
@ -755,8 +755,13 @@ export class Frame {
await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._focus(progress), this._apiName('focus')); await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._focus(progress), this._apiName('focus'));
} }
async textContent(selector: string, options: types.TimeoutOptions = {}): Promise<null|string> { async textContent(selector: string, options: types.TimeoutOptions = {}): Promise<string | null> {
return await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle.textContent(), this._apiName('textContent')); const info = selectors._parseSelector(selector);
const task = dom.textContentTask(info);
return this._page._runAbortableTask(async progress => {
progress.logger.info(`Retrieving text context from "${selector}"...`);
return this._scheduleRerunnableTask(progress, info.world, task);
}, this._page._timeoutSettings.timeout(options), this._apiName('textContent'));
} }
async innerText(selector: string, options: types.TimeoutOptions = {}): Promise<string> { async innerText(selector: string, options: types.TimeoutOptions = {}): Promise<string> {
@ -809,7 +814,6 @@ export class Frame {
return this._waitForFunctionExpression(String(pageFunction), typeof pageFunction === 'function', arg, options); return this._waitForFunctionExpression(String(pageFunction), typeof pageFunction === 'function', arg, options);
} }
async _waitForFunctionExpression<R>(expression: string, isFunction: boolean, arg: any, options: types.WaitForFunctionOptions = {}): Promise<js.SmartHandle<R>> { async _waitForFunctionExpression<R>(expression: string, isFunction: boolean, arg: any, options: types.WaitForFunctionOptions = {}): Promise<js.SmartHandle<R>> {
const { polling = 'raf' } = options; const { polling = 'raf' } = options;
if (helper.isString(polling)) if (helper.isString(polling))
@ -819,17 +823,14 @@ export class Frame {
else else
throw new Error('Unknown polling option: ' + polling); throw new Error('Unknown polling option: ' + polling);
const predicateBody = isFunction ? 'return (' + expression + ')(arg)' : 'return (' + expression + ')'; const predicateBody = isFunction ? 'return (' + expression + ')(arg)' : 'return (' + expression + ')';
const task = async (context: dom.FrameExecutionContext) => { const task: dom.SchedulableTask<R> = injectedScript => injectedScript.evaluateHandle((injectedScript, { predicateBody, polling, arg }) => {
const injectedScript = await context.injectedScript(); const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R;
return context.evaluateHandleInternal(({ injectedScript, predicateBody, polling, arg }) => { if (polling === 'raf')
const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R; return injectedScript.pollRaf((progress, continuePolling) => innerPredicate(arg) || continuePolling);
if (polling === 'raf') return injectedScript.pollInterval(polling, (progress, continuePolling) => innerPredicate(arg) || continuePolling);
return injectedScript.pollRaf((progress, continuePolling) => innerPredicate(arg) || continuePolling); }, { predicateBody, polling, arg });
return injectedScript.pollInterval(polling, (progress, continuePolling) => innerPredicate(arg) || continuePolling);
}, { injectedScript, predicateBody, polling, arg });
};
return this._page._runAbortableTask( return this._page._runAbortableTask(
progress => this._scheduleRerunnableTask(progress, 'main', task), progress => this._scheduleRerunnableHandleTask(progress, 'main', task),
this._page._timeoutSettings.timeout(options), this._apiName('waitForFunction')); this._page._timeoutSettings.timeout(options), this._apiName('waitForFunction'));
} }
@ -850,9 +851,19 @@ export class Frame {
this._parentFrame = null; this._parentFrame = null;
} }
private _scheduleRerunnableTask<T>(progress: Progress, world: types.World, task: SchedulableTask<T>): Promise<js.SmartHandle<T>> { private _scheduleRerunnableTask<T>(progress: Progress, world: types.World, task: dom.SchedulableTask<T>): Promise<T> {
const data = this._contextData.get(world)!; const data = this._contextData.get(world)!;
const rerunnableTask = new RerunnableTask(data, progress, task); const rerunnableTask = new RerunnableTask(data, progress, task, true /* returnByValue */);
if (this._detached)
rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.'));
if (data.context)
rerunnableTask.rerun(data.context);
return rerunnableTask.promise;
}
private _scheduleRerunnableHandleTask<T>(progress: Progress, world: types.World, task: dom.SchedulableTask<T>): Promise<js.SmartHandle<T>> {
const data = this._contextData.get(world)!;
const rerunnableTask = new RerunnableTask(data, progress, task, false /* returnByValue */);
if (this._detached) if (this._detached)
rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.')); rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.'));
if (data.context) if (data.context)
@ -905,20 +916,20 @@ export class Frame {
} }
} }
export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise<js.JSHandle<types.InjectedScriptPoll<T>>>; class RerunnableTask {
readonly promise: Promise<any>;
class RerunnableTask<T> { private _task: dom.SchedulableTask<any>;
readonly promise: Promise<js.SmartHandle<T>>; private _resolve: (result: any) => void = () => {};
private _task: SchedulableTask<T>;
private _resolve: (result: js.SmartHandle<T>) => void = () => {};
private _reject: (reason: Error) => void = () => {}; private _reject: (reason: Error) => void = () => {};
private _progress: Progress; private _progress: Progress;
private _returnByValue: boolean;
constructor(data: ContextData, progress: Progress, task: SchedulableTask<T>) { constructor(data: ContextData, progress: Progress, task: dom.SchedulableTask<any>, returnByValue: boolean) {
this._task = task; this._task = task;
this._progress = progress; this._progress = progress;
this._returnByValue = returnByValue;
data.rerunnableTasks.add(this); data.rerunnableTasks.add(this);
this.promise = new Promise<js.SmartHandle<T>>((resolve, reject) => { this.promise = new Promise<any>((resolve, reject) => {
// The task is either resolved with a value, or rejected with a meaningful evaluation error. // The task is either resolved with a value, or rejected with a meaningful evaluation error.
this._resolve = resolve; this._resolve = resolve;
this._reject = reject; this._reject = reject;
@ -931,8 +942,9 @@ class RerunnableTask<T> {
async rerun(context: dom.FrameExecutionContext) { async rerun(context: dom.FrameExecutionContext) {
try { try {
const pollHandler = new dom.InjectedScriptPollHandler(this._progress, await this._task(context)); const injectedScript = await context.injectedScript();
const result = await pollHandler.finishHandle(); const pollHandler = new dom.InjectedScriptPollHandler(this._progress, await this._task(injectedScript));
const result = this._returnByValue ? await pollHandler.finish() : await pollHandler.finishHandle();
this._resolve(result); this._resolve(result);
} catch (e) { } catch (e) {
// When the page is navigated, the promise is rejected. // When the page is navigated, the promise is rejected.

View File

@ -109,54 +109,6 @@ export class Selectors {
return result; return result;
} }
_waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden'): frames.SchedulableTask<Element | undefined> {
return async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, state }) => {
let lastElement: Element | undefined;
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
const visible = element ? injected.isVisible(element) : false;
if (lastElement !== element) {
lastElement = element;
if (!element)
progress.log(` selector did not resolve to any element`);
else
progress.log(` selector resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`);
}
switch (state) {
case 'attached':
return element ? element : continuePolling;
case 'detached':
return !element ? undefined : continuePolling;
case 'visible':
return visible ? element : continuePolling;
case 'hidden':
return !visible ? undefined : continuePolling;
}
});
}, { parsed: selector.parsed, state });
};
}
_dispatchEventTask(selector: SelectorInfo, type: string, eventInit: Object): frames.SchedulableTask<undefined> {
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, type, eventInit }) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (element)
injected.dispatchEvent(element, type, eventInit);
return element ? undefined : continuePolling;
});
}, { parsed: selector.parsed, type, eventInit });
};
return task;
}
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> { async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
const mainContext = await handle._page.mainFrame()._mainContext(); const mainContext = await handle._page.mainFrame()._mainContext();
const injectedScript = await mainContext.injectedScript(); const injectedScript = await mainContext.injectedScript();

View File

@ -97,6 +97,27 @@ describe('Page.dispatchEvent(click)', function() {
await watchdog; await watchdog;
expect(await page.evaluate(() => window.clicked)).toBe(true); expect(await page.evaluate(() => window.clicked)).toBe(true);
}); });
it('should be atomic', async({page}) => {
const createDummySelector = () => ({
create(root, target) {},
query(root, selector) {
const result = root.querySelector(selector);
if (result)
Promise.resolve().then(() => result.onclick = "");
return result;
},
queryAll(root, selector) {
const result = Array.from(root.querySelectorAll(selector));
for (const e of result)
Promise.resolve().then(() => result.onclick = "");
return result;
}
});
await utils.registerEngine('dispatchEvent', createDummySelector);
await page.setContent(`<div onclick="window._clicked=true">Hello</div>`);
await page.dispatchEvent('dispatchEvent=div', 'click');
expect(await page.evaluate(() => window._clicked)).toBe(true);
});
}); });
describe('Page.dispatchEvent(drag)', function() { describe('Page.dispatchEvent(drag)', function() {

View File

@ -476,6 +476,28 @@ describe('ElementHandle convenience API', function() {
expect(await handle.textContent()).toBe('Text,\nmore text'); expect(await handle.textContent()).toBe('Text,\nmore text');
expect(await page.textContent('#inner')).toBe('Text,\nmore text'); expect(await page.textContent('#inner')).toBe('Text,\nmore text');
}); });
it('textContent should be atomic', async({page}) => {
const createDummySelector = () => ({
create(root, target) {},
query(root, selector) {
const result = root.querySelector(selector);
if (result)
Promise.resolve().then(() => result.textContent = 'modified');
return result;
},
queryAll(root, selector) {
const result = Array.from(root.querySelectorAll(selector));
for (const e of result)
Promise.resolve().then(() => result.textContent = 'modified');
return result;
}
});
await utils.registerEngine('textContent', createDummySelector);
await page.setContent(`<div>Hello</div>`);
const tc = await page.textContent('textContent=div');
expect(tc).toBe('Hello');
expect(await page.evaluate(() => document.querySelector('div').textContent)).toBe('modified');
});
}); });
describe('ElementHandle.check', () => { describe('ElementHandle.check', () => {

View File

@ -16,16 +16,8 @@
*/ */
const path = require('path'); const path = require('path');
const {FFOX, CHROMIUM, WEBKIT} = require('./utils').testOptions(browserType); const utils = require('./utils');
const {FFOX, CHROMIUM, WEBKIT} = utils.testOptions(browserType);
async function registerEngine(name, script, options) {
try {
await playwright.selectors.register(name, script, options);
} catch (e) {
if (!e.message.includes('has been already registered'))
throw e;
}
}
describe('Page.$eval', function() { describe('Page.$eval', function() {
it('should work with css selector', async({page, server}) => { it('should work with css selector', async({page, server}) => {
@ -764,15 +756,19 @@ describe('selectors.register', () => {
return Array.from(root.querySelectorAll(selector)); return Array.from(root.querySelectorAll(selector));
} }
}); });
await registerEngine('tag', `(${createTagSelector.toString()})()`); await utils.registerEngine('tag', `(${createTagSelector.toString()})()`);
await page.setContent('<div><span></span></div><div></div>'); await page.setContent('<div><span></span></div><div></div>');
expect(await playwright.selectors._createSelector('tag', await page.$('div'))).toBe('DIV'); expect(await playwright.selectors._createSelector('tag', await page.$('div'))).toBe('DIV');
expect(await page.$eval('tag=DIV', e => e.nodeName)).toBe('DIV'); expect(await page.$eval('tag=DIV', e => e.nodeName)).toBe('DIV');
expect(await page.$eval('tag=SPAN', e => e.nodeName)).toBe('SPAN'); expect(await page.$eval('tag=SPAN', e => e.nodeName)).toBe('SPAN');
expect(await page.$$eval('tag=DIV', es => es.length)).toBe(2); expect(await page.$$eval('tag=DIV', es => es.length)).toBe(2);
// Selector names are case-sensitive.
const error = await page.$('tAG=DIV').catch(e => e);
expect(error.message).toBe('Unknown engine "tAG" while parsing selector tAG=DIV');
}); });
it('should work with path', async ({page}) => { it('should work with path', async ({page}) => {
await registerEngine('foo', { path: path.join(__dirname, 'assets/sectionselectorengine.js') }); await utils.registerEngine('foo', { path: path.join(__dirname, 'assets/sectionselectorengine.js') });
await page.setContent('<section></section>'); await page.setContent('<section></section>');
expect(await page.$eval('foo=whatever', e => e.nodeName)).toBe('SECTION'); expect(await page.$eval('foo=whatever', e => e.nodeName)).toBe('SECTION');
}); });
@ -786,8 +782,8 @@ describe('selectors.register', () => {
return [document.body, document.documentElement, window.__answer]; return [document.body, document.documentElement, window.__answer];
} }
}); });
await registerEngine('main', createDummySelector); await utils.registerEngine('main', createDummySelector);
await registerEngine('isolated', createDummySelector, { contentScript: true }); await utils.registerEngine('isolated', createDummySelector, { contentScript: true });
await page.setContent('<div><span><section></section></span></div>'); await page.setContent('<div><span><section></section></span></div>');
await page.evaluate(() => window.__answer = document.querySelector('span')); await page.evaluate(() => window.__answer = document.querySelector('span'));
// Works in main if asked. // Works in main if asked.
@ -826,7 +822,9 @@ describe('selectors.register', () => {
error = await playwright.selectors.register('$', createDummySelector).catch(e => e); error = await playwright.selectors.register('$', createDummySelector).catch(e => e);
expect(error.message).toBe('Selector engine name may only contain [a-zA-Z0-9_] characters'); expect(error.message).toBe('Selector engine name may only contain [a-zA-Z0-9_] characters');
await registerEngine('dummy', createDummySelector); // Selector names are case-sensitive.
await utils.registerEngine('dummy', createDummySelector);
await utils.registerEngine('duMMy', createDummySelector);
error = await playwright.selectors.register('dummy', createDummySelector).catch(e => e); error = await playwright.selectors.register('dummy', createDummySelector).catch(e => e);
expect(error.message).toBe('"dummy" selector engine has been already registered'); expect(error.message).toBe('"dummy" selector engine has been already registered');

View File

@ -94,6 +94,15 @@ const utils = module.exports = {
expect(await page.evaluate('window.innerHeight')).toBe(height); expect(await page.evaluate('window.innerHeight')).toBe(height);
}, },
registerEngine: async (name, script, options) => {
try {
await playwright.selectors.register(name, script, options);
} catch (e) {
if (!e.message.includes('has been already registered'))
throw e;
}
},
initializeFlakinessDashboardIfNeeded: async function(testRunner) { initializeFlakinessDashboardIfNeeded: async function(testRunner) {
// Generate testIDs for all tests and verify they don't clash. // Generate testIDs for all tests and verify they don't clash.
// This will add |test.testId| for every test. // This will add |test.testId| for every test.