mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
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:
parent
0a34d05b3e
commit
bf67245de6
37
src/dom.ts
37
src/dom.ts
@ -29,6 +29,7 @@ import { selectors } from './selectors';
|
||||
import * as types from './types';
|
||||
import { NotConnectedError, TimeoutError } from './errors';
|
||||
import { Log, logError } from './logger';
|
||||
import { Progress } from './progress';
|
||||
|
||||
export type PointerActionOptions = {
|
||||
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[] {
|
||||
return files.map(file => ({
|
||||
name: file.name,
|
||||
|
||||
@ -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> {
|
||||
readonly promise: Promise<types.SmartHandle<T>>;
|
||||
@ -919,29 +919,19 @@ class RerunnableTask<T> {
|
||||
}
|
||||
|
||||
terminate(error: Error) {
|
||||
this._progress.cancel(error);
|
||||
this._reject(error);
|
||||
}
|
||||
|
||||
async rerun(context: dom.FrameExecutionContext) {
|
||||
let poll: js.JSHandle<types.CancelablePoll<T>> | 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);
|
||||
|
||||
let pollHandler: dom.InjectedScriptPollHandler | null = null;
|
||||
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);
|
||||
cancelPoll();
|
||||
this._resolve(result);
|
||||
} catch (e) {
|
||||
cancelPoll();
|
||||
if (pollHandler)
|
||||
pollHandler.cancel();
|
||||
|
||||
// When the page is navigated, the promise is rejected.
|
||||
// We will try again in the new execution context.
|
||||
|
||||
@ -22,14 +22,7 @@ import { createTextSelector } from './textSelectorEngine';
|
||||
import { XPathEngine } from './xpathSelectorEngine';
|
||||
|
||||
type Falsy = false | 0 | '' | undefined | null;
|
||||
type Predicate<T> = () => T | Falsy;
|
||||
type InjectedScriptProgress = {
|
||||
canceled: boolean;
|
||||
};
|
||||
|
||||
function asCancelablePoll<T>(result: T): types.CancelablePoll<T> {
|
||||
return { result: Promise.resolve(result), cancel: () => {} };
|
||||
}
|
||||
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
|
||||
|
||||
export default class InjectedScript {
|
||||
readonly engines: Map<string, SelectorEngine>;
|
||||
@ -111,14 +104,14 @@ export default class InjectedScript {
|
||||
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;
|
||||
const result = new Promise<T>(x => fulfill = x);
|
||||
|
||||
const onRaf = () => {
|
||||
if (progress.canceled)
|
||||
return;
|
||||
const success = predicate();
|
||||
const success = predicate(progress);
|
||||
if (success)
|
||||
fulfill(success);
|
||||
else
|
||||
@ -129,13 +122,13 @@ export default class InjectedScript {
|
||||
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;
|
||||
const result = new Promise<T>(x => fulfill = x);
|
||||
const onTimeout = () => {
|
||||
if (progress.canceled)
|
||||
return;
|
||||
const success = predicate();
|
||||
const success = predicate(progress);
|
||||
if (success)
|
||||
fulfill(success);
|
||||
else
|
||||
@ -146,11 +139,39 @@ export default class InjectedScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.CancelablePoll<T> {
|
||||
const progress = { canceled: false };
|
||||
const cancel = () => { progress.canceled = true; };
|
||||
const result = polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate);
|
||||
return { result, cancel };
|
||||
private _runCancellablePoll<T>(poll: (progess: types.InjectedScriptProgress) => Promise<T>): types.InjectedScriptPoll<T> {
|
||||
let currentLogs: string[] = [];
|
||||
let logReady = () => {};
|
||||
const createLogsPromise = () => new Promise<types.InjectedScriptLogs>(fulfill => {
|
||||
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; } {
|
||||
@ -327,50 +348,52 @@ export default class InjectedScript {
|
||||
input.dispatchEvent(new Event('change', { 'bubbles': true }));
|
||||
}
|
||||
|
||||
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.CancelablePoll<types.InjectedScriptResult> {
|
||||
if (!node.isConnected)
|
||||
return asCancelablePoll({ status: 'notconnected' });
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||
if (!element)
|
||||
return asCancelablePoll({ status: 'notconnected' });
|
||||
|
||||
let lastRect: types.Rect | undefined;
|
||||
let counter = 0;
|
||||
let samePositionCounter = 0;
|
||||
let lastTime = 0;
|
||||
return this.poll('raf', (): types.InjectedScriptResult | false => {
|
||||
// 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
|
||||
// and only force layout during actual rafs as a small optimisation.
|
||||
if (++counter === 1)
|
||||
return false;
|
||||
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult> {
|
||||
return this._runCancellablePoll(async progress => {
|
||||
if (!node.isConnected)
|
||||
return { status: 'notconnected' };
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||||
if (!element)
|
||||
return { status: 'notconnected' };
|
||||
|
||||
// Drop frames that are shorter than 16ms - WebKit Win bug.
|
||||
const time = performance.now();
|
||||
if (rafCount > 1 && time - lastTime < 15)
|
||||
return false;
|
||||
lastTime = time;
|
||||
let lastRect: types.Rect | undefined;
|
||||
let counter = 0;
|
||||
let samePositionCounter = 0;
|
||||
let lastTime = 0;
|
||||
return this._pollRaf(progress, (): types.InjectedScriptResult | false => {
|
||||
// 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
|
||||
// and only force layout during actual rafs as a small optimisation.
|
||||
if (++counter === 1)
|
||||
return false;
|
||||
if (!node.isConnected)
|
||||
return { status: 'notconnected' };
|
||||
|
||||
// Note: this logic should be similar to isVisible() to avoid surprises.
|
||||
const clientRect = element.getBoundingClientRect();
|
||||
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
|
||||
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height && rect.width > 0 && rect.height > 0;
|
||||
lastRect = rect;
|
||||
if (samePosition)
|
||||
++samePositionCounter;
|
||||
else
|
||||
samePositionCounter = 0;
|
||||
const isDisplayedAndStable = samePositionCounter >= rafCount;
|
||||
// Drop frames that are shorter than 16ms - WebKit Win bug.
|
||||
const time = performance.now();
|
||||
if (rafCount > 1 && time - lastTime < 15)
|
||||
return false;
|
||||
lastTime = time;
|
||||
|
||||
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
|
||||
const isVisible = !!style && style.visibility !== 'hidden';
|
||||
// Note: this logic should be similar to isVisible() to avoid surprises.
|
||||
const clientRect = element.getBoundingClientRect();
|
||||
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
|
||||
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height && rect.width > 0 && rect.height > 0;
|
||||
lastRect = rect;
|
||||
if (samePosition)
|
||||
++samePositionCounter;
|
||||
else
|
||||
samePositionCounter = 0;
|
||||
const isDisplayedAndStable = samePositionCounter >= rafCount;
|
||||
|
||||
const elementOrButton = element.closest('button, [role=button]') || element;
|
||||
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
|
||||
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
|
||||
const isVisible = !!style && style.visibility !== 'hidden';
|
||||
|
||||
return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false;
|
||||
const elementOrButton = element.closest('button, [role=button]') || element;
|
||||
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
|
||||
|
||||
return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -421,6 +444,12 @@ export default class InjectedScript {
|
||||
}
|
||||
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'>([
|
||||
|
||||
@ -74,6 +74,10 @@ export class Progress {
|
||||
this._logger = logger;
|
||||
}
|
||||
|
||||
isCanceled(): boolean {
|
||||
return !this._running;
|
||||
}
|
||||
|
||||
cleanupWhenCanceled(cleanup: () => any) {
|
||||
if (this._running)
|
||||
this._cleanups.push(cleanup);
|
||||
|
||||
@ -116,17 +116,42 @@ export class Selectors {
|
||||
const task = async (context: dom.FrameExecutionContext) => {
|
||||
const injectedScript = await context.injectedScript();
|
||||
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 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) {
|
||||
case 'attached':
|
||||
case 'attached': {
|
||||
return element || false;
|
||||
case 'detached':
|
||||
}
|
||||
case 'detached': {
|
||||
if (element)
|
||||
log('');
|
||||
return !element;
|
||||
case 'visible':
|
||||
return element && injected.isVisible(element) ? element : false;
|
||||
case 'hidden':
|
||||
return !element || !injected.isVisible(element);
|
||||
}
|
||||
case 'visible': {
|
||||
const result = element && injected.isVisible(element) ? element : false;
|
||||
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 });
|
||||
|
||||
@ -169,7 +169,14 @@ export type InjectedScriptResult<T = undefined> =
|
||||
{ status: 'notconnected' } |
|
||||
{ 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>,
|
||||
logs: Promise<InjectedScriptLogs>,
|
||||
cancel: () => void,
|
||||
};
|
||||
|
||||
@ -18,6 +18,11 @@
|
||||
const utils = require('./utils');
|
||||
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() {
|
||||
it('should timeout', async({page, server}) => {
|
||||
const startTime = Date.now();
|
||||
@ -197,6 +202,67 @@ describe('Frame.waitForSelector', function() {
|
||||
const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue());
|
||||
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}) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
const watchdog = page.waitForSelector('span');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user