feat(debug): stream logs from waitForSelector (#2434)

- we can now stream logs from InjectedScriptProgress to Progress;
- waitForSelector task uses it to report intermediate elements.
This commit is contained in:
Dmitry Gozman 2020-06-01 15:48:23 -07:00 committed by GitHub
parent 0a34d05b3e
commit bf67245de6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 237 additions and 79 deletions

View File

@ -29,6 +29,7 @@ import { selectors } from './selectors';
import * as types from './types'; import * as types from './types';
import { NotConnectedError, TimeoutError } from './errors'; import { NotConnectedError, TimeoutError } from './errors';
import { Log, logError } from './logger'; import { Log, logError } from './logger';
import { Progress } from './progress';
export type PointerActionOptions = { export type PointerActionOptions = {
modifiers?: input.Modifier[]; modifiers?: input.Modifier[];
@ -514,6 +515,42 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
} }
// Handles an InjectedScriptPoll running in injected script:
// - streams logs into progress;
// - cancels the poll when progress cancels.
export class InjectedScriptPollHandler {
private _progress: Progress;
private _poll: js.JSHandle<types.InjectedScriptPoll<any>> | null;
constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<any>>) {
this._progress = progress;
this._poll = poll;
this._progress.cleanupWhenCanceled(() => this.cancel());
this._streamLogs(poll.evaluateHandle(poll => poll.logs));
}
private _streamLogs(logsPromise: Promise<js.JSHandle<types.InjectedScriptLogs>>) {
// We continuously get a chunk of logs, stream them to the progress and wait for the next chunk.
logsPromise.catch(e => null).then(logs => {
if (!logs || !this._poll || this._progress.isCanceled())
return;
logs.evaluate(logs => logs.current).catch(e => [] as string[]).then(messages => {
for (const message of messages)
this._progress.log(inputLog, message);
});
this._streamLogs(logs.evaluateHandle(logs => logs.next));
});
}
cancel() {
if (!this._poll)
return;
const copy = this._poll;
this._poll = null;
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose());
}
}
export function toFileTransferPayload(files: types.FilePayload[]): types.FileTransferPayload[] { export function toFileTransferPayload(files: types.FilePayload[]): types.FileTransferPayload[] {
return files.map(file => ({ return files.map(file => ({
name: file.name, name: file.name,

View File

@ -898,7 +898,7 @@ export class Frame {
} }
} }
export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise<js.JSHandle<types.CancelablePoll<T>>>; export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise<js.JSHandle<types.InjectedScriptPoll<T>>>;
class RerunnableTask<T> { class RerunnableTask<T> {
readonly promise: Promise<types.SmartHandle<T>>; readonly promise: Promise<types.SmartHandle<T>>;
@ -919,29 +919,19 @@ class RerunnableTask<T> {
} }
terminate(error: Error) { terminate(error: Error) {
this._progress.cancel(error); this._reject(error);
} }
async rerun(context: dom.FrameExecutionContext) { async rerun(context: dom.FrameExecutionContext) {
let poll: js.JSHandle<types.CancelablePoll<T>> | null = null; let pollHandler: dom.InjectedScriptPollHandler | null = null;
// On timeout or error, cancel current poll.
const cancelPoll = () => {
if (!poll)
return;
const copy = poll;
poll = null;
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose());
};
this._progress.cleanupWhenCanceled(cancelPoll);
try { try {
poll = await this._task(context); const poll = await this._task(context);
pollHandler = new dom.InjectedScriptPollHandler(this._progress, poll);
const result = await poll.evaluateHandle(poll => poll.result); const result = await poll.evaluateHandle(poll => poll.result);
cancelPoll();
this._resolve(result); this._resolve(result);
} catch (e) { } catch (e) {
cancelPoll(); if (pollHandler)
pollHandler.cancel();
// When the page is navigated, the promise is rejected. // When the page is navigated, the promise is rejected.
// We will try again in the new execution context. // We will try again in the new execution context.

View File

@ -22,14 +22,7 @@ import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine';
type Falsy = false | 0 | '' | undefined | null; type Falsy = false | 0 | '' | undefined | null;
type Predicate<T> = () => T | Falsy; type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
type InjectedScriptProgress = {
canceled: boolean;
};
function asCancelablePoll<T>(result: T): types.CancelablePoll<T> {
return { result: Promise.resolve(result), cancel: () => {} };
}
export default class InjectedScript { export default class InjectedScript {
readonly engines: Map<string, SelectorEngine>; readonly engines: Map<string, SelectorEngine>;
@ -111,14 +104,14 @@ export default class InjectedScript {
return rect.width > 0 && rect.height > 0; return rect.width > 0 && rect.height > 0;
} }
private _pollRaf<T>(progress: InjectedScriptProgress, predicate: Predicate<T>): Promise<T> { private _pollRaf<T>(progress: types.InjectedScriptProgress, predicate: Predicate<T>): Promise<T> {
let fulfill: (result: T) => void; let fulfill: (result: T) => void;
const result = new Promise<T>(x => fulfill = x); const result = new Promise<T>(x => fulfill = x);
const onRaf = () => { const onRaf = () => {
if (progress.canceled) if (progress.canceled)
return; return;
const success = predicate(); const success = predicate(progress);
if (success) if (success)
fulfill(success); fulfill(success);
else else
@ -129,13 +122,13 @@ export default class InjectedScript {
return result; return result;
} }
private _pollInterval<T>(progress: InjectedScriptProgress, pollInterval: number, predicate: Predicate<T>): Promise<T> { private _pollInterval<T>(progress: types.InjectedScriptProgress, pollInterval: number, predicate: Predicate<T>): Promise<T> {
let fulfill: (result: T) => void; let fulfill: (result: T) => void;
const result = new Promise<T>(x => fulfill = x); const result = new Promise<T>(x => fulfill = x);
const onTimeout = () => { const onTimeout = () => {
if (progress.canceled) if (progress.canceled)
return; return;
const success = predicate(); const success = predicate(progress);
if (success) if (success)
fulfill(success); fulfill(success);
else else
@ -146,11 +139,39 @@ export default class InjectedScript {
return result; return result;
} }
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.CancelablePoll<T> { private _runCancellablePoll<T>(poll: (progess: types.InjectedScriptProgress) => Promise<T>): types.InjectedScriptPoll<T> {
const progress = { canceled: false }; let currentLogs: string[] = [];
const cancel = () => { progress.canceled = true; }; let logReady = () => {};
const result = polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate); const createLogsPromise = () => new Promise<types.InjectedScriptLogs>(fulfill => {
return { result, cancel }; logReady = () => {
const current = currentLogs;
currentLogs = [];
fulfill({ current, next: createLogsPromise() });
};
});
const progress: types.InjectedScriptProgress = {
canceled: false,
log: (message: string) => {
currentLogs.push(message);
logReady();
},
};
// It is important to create logs promise before running the poll to capture logs from the first run.
const logs = createLogsPromise();
return {
logs,
result: poll(progress),
cancel: () => { progress.canceled = true; },
};
}
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.InjectedScriptPoll<T> {
return this._runCancellablePoll(progress => {
return polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate);
});
} }
getElementBorderWidth(node: Node): { left: number; top: number; } { getElementBorderWidth(node: Node): { left: number; top: number; } {
@ -327,18 +348,19 @@ export default class InjectedScript {
input.dispatchEvent(new Event('change', { 'bubbles': true })); input.dispatchEvent(new Event('change', { 'bubbles': true }));
} }
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.CancelablePoll<types.InjectedScriptResult> { waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult> {
return this._runCancellablePoll(async progress => {
if (!node.isConnected) if (!node.isConnected)
return asCancelablePoll({ status: 'notconnected' }); return { status: 'notconnected' };
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element) if (!element)
return asCancelablePoll({ status: 'notconnected' }); return { status: 'notconnected' };
let lastRect: types.Rect | undefined; let lastRect: types.Rect | undefined;
let counter = 0; let counter = 0;
let samePositionCounter = 0; let samePositionCounter = 0;
let lastTime = 0; let lastTime = 0;
return this.poll('raf', (): types.InjectedScriptResult | false => { return this._pollRaf(progress, (): types.InjectedScriptResult | false => {
// First raf happens in the same animation frame as evaluation, so it does not produce // First raf happens in the same animation frame as evaluation, so it does not produce
// any client rect difference compared to synchronous call. We skip the synchronous call // any client rect difference compared to synchronous call. We skip the synchronous call
// and only force layout during actual rafs as a small optimisation. // and only force layout during actual rafs as a small optimisation.
@ -372,6 +394,7 @@ export default class InjectedScript {
return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false; return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false;
}); });
});
} }
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<boolean> { checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<boolean> {
@ -421,6 +444,12 @@ export default class InjectedScript {
} }
return element; return element;
} }
previewElement(element: Element): string {
const id = element.id ? '#' + element.id : '';
const classes = Array.from(element.classList).map(c => '.' + c).join('');
return `${element.nodeName.toLowerCase()}${id}${classes}`;
}
} }
const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'drag'>([ const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'drag'>([

View File

@ -74,6 +74,10 @@ export class Progress {
this._logger = logger; this._logger = logger;
} }
isCanceled(): boolean {
return !this._running;
}
cleanupWhenCanceled(cleanup: () => any) { cleanupWhenCanceled(cleanup: () => any) {
if (this._running) if (this._running)
this._cleanups.push(cleanup); this._cleanups.push(cleanup);

View File

@ -116,17 +116,42 @@ export class Selectors {
const task = async (context: dom.FrameExecutionContext) => { const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript(); const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, state }) => { return injectedScript.evaluateHandle((injected, { parsed, state }) => {
return injected.poll('raf', () => { let lastElement: Element | undefined;
return injected.poll('raf', (progress: types.InjectedScriptProgress) => {
const element = injected.querySelector(parsed, document); const element = injected.querySelector(parsed, document);
const log = (suffix: string) => {
if (lastElement === element)
return;
lastElement = element;
if (!element)
progress.log(`selector did not resolve to any element`);
else
progress.log(`selector resolved to "${injected.previewElement(element)}"${suffix ? ' ' + suffix : ''}`);
};
switch (state) { switch (state) {
case 'attached': case 'attached': {
return element || false; return element || false;
case 'detached': }
case 'detached': {
if (element)
log('');
return !element; return !element;
case 'visible': }
return element && injected.isVisible(element) ? element : false; case 'visible': {
case 'hidden': const result = element && injected.isVisible(element) ? element : false;
return !element || !injected.isVisible(element); if (!result)
log('that is not visible');
return result;
}
case 'hidden': {
const result = !element || !injected.isVisible(element);
if (!result)
log('that is still visible');
return result;
}
} }
}); });
}, { parsed, state }); }, { parsed, state });

View File

@ -169,7 +169,14 @@ export type InjectedScriptResult<T = undefined> =
{ status: 'notconnected' } | { status: 'notconnected' } |
{ status: 'error', error: string }; { status: 'error', error: string };
export type CancelablePoll<T> = { export type InjectedScriptProgress = {
canceled: boolean,
log: (message: string) => void,
};
export type InjectedScriptLogs = { current: string[], next: Promise<InjectedScriptLogs> };
export type InjectedScriptPoll<T> = {
result: Promise<T>, result: Promise<T>,
logs: Promise<InjectedScriptLogs>,
cancel: () => void, cancel: () => void,
}; };

View File

@ -18,6 +18,11 @@
const utils = require('./utils'); const utils = require('./utils');
const {FFOX, CHROMIUM, WEBKIT} = utils.testOptions(browserType); const {FFOX, CHROMIUM, WEBKIT} = utils.testOptions(browserType);
async function giveItTimeToLog(frame) {
await frame.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
await frame.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
}
describe('Page.waitForTimeout', function() { describe('Page.waitForTimeout', function() {
it('should timeout', async({page, server}) => { it('should timeout', async({page, server}) => {
const startTime = Date.now(); const startTime = Date.now();
@ -197,6 +202,67 @@ describe('Frame.waitForSelector', function() {
const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue()); const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue());
expect(tagName).toBe('DIV'); expect(tagName).toBe('DIV');
}); });
it('should report logs while waiting for visible', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
const frame = page.mainFrame();
const watchdog = frame.waitForSelector('div', { timeout: 5000 });
await frame.evaluate(() => {
const div = document.createElement('div');
div.className = 'foo bar';
div.id = 'mydiv';
div.style.display = 'none';
document.body.appendChild(div);
});
await giveItTimeToLog(frame);
await frame.evaluate(() => document.querySelector('div').remove());
await giveItTimeToLog(frame);
await frame.evaluate(() => {
const div = document.createElement('div');
div.className = 'another';
div.style.display = 'none';
document.body.appendChild(div);
});
await giveItTimeToLog(frame);
const error = await watchdog.catch(e => e);
expect(error.message).toContain(`Timeout 5000ms exceeded during frame.waitForSelector.`);
expect(error.message).toContain(`Waiting for selector "div" to be visible...`);
expect(error.message).toContain(`selector resolved to "div#mydiv.foo.bar" that is not visible`);
expect(error.message).toContain(`selector did not resolve to any element`);
expect(error.message).toContain(`selector resolved to "div.another" that is not visible`);
});
it('should report logs while waiting for hidden', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
const frame = page.mainFrame();
await frame.evaluate(() => {
const div = document.createElement('div');
div.className = 'foo bar';
div.id = 'mydiv';
div.textContent = 'hello';
document.body.appendChild(div);
});
const watchdog = frame.waitForSelector('div', { state: 'hidden', timeout: 5000 });
await giveItTimeToLog(frame);
await frame.evaluate(() => {
document.querySelector('div').remove();
const div = document.createElement('div');
div.className = 'another';
div.textContent = 'hello';
document.body.appendChild(div);
});
await giveItTimeToLog(frame);
const error = await watchdog.catch(e => e);
expect(error.message).toContain(`Timeout 5000ms exceeded during frame.waitForSelector.`);
expect(error.message).toContain(`Waiting for selector "div" to be hidden...`);
expect(error.message).toContain(`selector resolved to "div#mydiv.foo.bar" that is still visible`);
expect(error.message).toContain(`selector resolved to "div.another" that is still visible`);
});
it('should resolve promise when node is added in shadow dom', async({page, server}) => { it('should resolve promise when node is added in shadow dom', async({page, server}) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
const watchdog = page.waitForSelector('span'); const watchdog = page.waitForSelector('span');