Skip to content

Commit bf67245

Browse files
authored
feat(debug): stream logs from waitForSelector (#2434)
- we can now stream logs from InjectedScriptProgress to Progress; - waitForSelector task uses it to report intermediate elements.
1 parent 0a34d05 commit bf67245

File tree

7 files changed

+241
-83
lines changed

7 files changed

+241
-83
lines changed

src/dom.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { selectors } from './selectors';
2929
import * as types from './types';
3030
import { NotConnectedError, TimeoutError } from './errors';
3131
import { Log, logError } from './logger';
32+
import { Progress } from './progress';
3233

3334
export type PointerActionOptions = {
3435
modifiers?: input.Modifier[];
@@ -514,6 +515,42 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
514515
}
515516
}
516517

518+
// Handles an InjectedScriptPoll running in injected script:
519+
// - streams logs into progress;
520+
// - cancels the poll when progress cancels.
521+
export class InjectedScriptPollHandler {
522+
private _progress: Progress;
523+
private _poll: js.JSHandle<types.InjectedScriptPoll<any>> | null;
524+
525+
constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<any>>) {
526+
this._progress = progress;
527+
this._poll = poll;
528+
this._progress.cleanupWhenCanceled(() => this.cancel());
529+
this._streamLogs(poll.evaluateHandle(poll => poll.logs));
530+
}
531+
532+
private _streamLogs(logsPromise: Promise<js.JSHandle<types.InjectedScriptLogs>>) {
533+
// We continuously get a chunk of logs, stream them to the progress and wait for the next chunk.
534+
logsPromise.catch(e => null).then(logs => {
535+
if (!logs || !this._poll || this._progress.isCanceled())
536+
return;
537+
logs.evaluate(logs => logs.current).catch(e => [] as string[]).then(messages => {
538+
for (const message of messages)
539+
this._progress.log(inputLog, message);
540+
});
541+
this._streamLogs(logs.evaluateHandle(logs => logs.next));
542+
});
543+
}
544+
545+
cancel() {
546+
if (!this._poll)
547+
return;
548+
const copy = this._poll;
549+
this._poll = null;
550+
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose());
551+
}
552+
}
553+
517554
export function toFileTransferPayload(files: types.FilePayload[]): types.FileTransferPayload[] {
518555
return files.map(file => ({
519556
name: file.name,

src/frames.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,7 @@ export class Frame {
898898
}
899899
}
900900

901-
export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise<js.JSHandle<types.CancelablePoll<T>>>;
901+
export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise<js.JSHandle<types.InjectedScriptPoll<T>>>;
902902

903903
class RerunnableTask<T> {
904904
readonly promise: Promise<types.SmartHandle<T>>;
@@ -919,29 +919,19 @@ class RerunnableTask<T> {
919919
}
920920

921921
terminate(error: Error) {
922-
this._progress.cancel(error);
922+
this._reject(error);
923923
}
924924

925925
async rerun(context: dom.FrameExecutionContext) {
926-
let poll: js.JSHandle<types.CancelablePoll<T>> | null = null;
927-
928-
// On timeout or error, cancel current poll.
929-
const cancelPoll = () => {
930-
if (!poll)
931-
return;
932-
const copy = poll;
933-
poll = null;
934-
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose());
935-
};
936-
this._progress.cleanupWhenCanceled(cancelPoll);
937-
926+
let pollHandler: dom.InjectedScriptPollHandler | null = null;
938927
try {
939-
poll = await this._task(context);
928+
const poll = await this._task(context);
929+
pollHandler = new dom.InjectedScriptPollHandler(this._progress, poll);
940930
const result = await poll.evaluateHandle(poll => poll.result);
941-
cancelPoll();
942931
this._resolve(result);
943932
} catch (e) {
944-
cancelPoll();
933+
if (pollHandler)
934+
pollHandler.cancel();
945935

946936
// When the page is navigated, the promise is rejected.
947937
// We will try again in the new execution context.

src/injected/injectedScript.ts

Lines changed: 87 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,7 @@ import { createTextSelector } from './textSelectorEngine';
2222
import { XPathEngine } from './xpathSelectorEngine';
2323

2424
type Falsy = false | 0 | '' | undefined | null;
25-
type Predicate<T> = () => T | Falsy;
26-
type InjectedScriptProgress = {
27-
canceled: boolean;
28-
};
29-
30-
function asCancelablePoll<T>(result: T): types.CancelablePoll<T> {
31-
return { result: Promise.resolve(result), cancel: () => {} };
32-
}
25+
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
3326

3427
export default class InjectedScript {
3528
readonly engines: Map<string, SelectorEngine>;
@@ -111,14 +104,14 @@ export default class InjectedScript {
111104
return rect.width > 0 && rect.height > 0;
112105
}
113106

114-
private _pollRaf<T>(progress: InjectedScriptProgress, predicate: Predicate<T>): Promise<T> {
107+
private _pollRaf<T>(progress: types.InjectedScriptProgress, predicate: Predicate<T>): Promise<T> {
115108
let fulfill: (result: T) => void;
116109
const result = new Promise<T>(x => fulfill = x);
117110

118111
const onRaf = () => {
119112
if (progress.canceled)
120113
return;
121-
const success = predicate();
114+
const success = predicate(progress);
122115
if (success)
123116
fulfill(success);
124117
else
@@ -129,13 +122,13 @@ export default class InjectedScript {
129122
return result;
130123
}
131124

132-
private _pollInterval<T>(progress: InjectedScriptProgress, pollInterval: number, predicate: Predicate<T>): Promise<T> {
125+
private _pollInterval<T>(progress: types.InjectedScriptProgress, pollInterval: number, predicate: Predicate<T>): Promise<T> {
133126
let fulfill: (result: T) => void;
134127
const result = new Promise<T>(x => fulfill = x);
135128
const onTimeout = () => {
136129
if (progress.canceled)
137130
return;
138-
const success = predicate();
131+
const success = predicate(progress);
139132
if (success)
140133
fulfill(success);
141134
else
@@ -146,11 +139,39 @@ export default class InjectedScript {
146139
return result;
147140
}
148141

149-
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.CancelablePoll<T> {
150-
const progress = { canceled: false };
151-
const cancel = () => { progress.canceled = true; };
152-
const result = polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate);
153-
return { result, cancel };
142+
private _runCancellablePoll<T>(poll: (progess: types.InjectedScriptProgress) => Promise<T>): types.InjectedScriptPoll<T> {
143+
let currentLogs: string[] = [];
144+
let logReady = () => {};
145+
const createLogsPromise = () => new Promise<types.InjectedScriptLogs>(fulfill => {
146+
logReady = () => {
147+
const current = currentLogs;
148+
currentLogs = [];
149+
fulfill({ current, next: createLogsPromise() });
150+
};
151+
});
152+
153+
const progress: types.InjectedScriptProgress = {
154+
canceled: false,
155+
log: (message: string) => {
156+
currentLogs.push(message);
157+
logReady();
158+
},
159+
};
160+
161+
// It is important to create logs promise before running the poll to capture logs from the first run.
162+
const logs = createLogsPromise();
163+
164+
return {
165+
logs,
166+
result: poll(progress),
167+
cancel: () => { progress.canceled = true; },
168+
};
169+
}
170+
171+
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.InjectedScriptPoll<T> {
172+
return this._runCancellablePoll(progress => {
173+
return polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate);
174+
});
154175
}
155176

156177
getElementBorderWidth(node: Node): { left: number; top: number; } {
@@ -327,50 +348,52 @@ export default class InjectedScript {
327348
input.dispatchEvent(new Event('change', { 'bubbles': true }));
328349
}
329350

330-
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.CancelablePoll<types.InjectedScriptResult> {
331-
if (!node.isConnected)
332-
return asCancelablePoll({ status: 'notconnected' });
333-
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
334-
if (!element)
335-
return asCancelablePoll({ status: 'notconnected' });
336-
337-
let lastRect: types.Rect | undefined;
338-
let counter = 0;
339-
let samePositionCounter = 0;
340-
let lastTime = 0;
341-
return this.poll('raf', (): types.InjectedScriptResult | false => {
342-
// First raf happens in the same animation frame as evaluation, so it does not produce
343-
// any client rect difference compared to synchronous call. We skip the synchronous call
344-
// and only force layout during actual rafs as a small optimisation.
345-
if (++counter === 1)
346-
return false;
351+
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult> {
352+
return this._runCancellablePoll(async progress => {
347353
if (!node.isConnected)
348354
return { status: 'notconnected' };
355+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
356+
if (!element)
357+
return { status: 'notconnected' };
349358

350-
// Drop frames that are shorter than 16ms - WebKit Win bug.
351-
const time = performance.now();
352-
if (rafCount > 1 && time - lastTime < 15)
353-
return false;
354-
lastTime = time;
355-
356-
// Note: this logic should be similar to isVisible() to avoid surprises.
357-
const clientRect = element.getBoundingClientRect();
358-
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
359-
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;
360-
lastRect = rect;
361-
if (samePosition)
362-
++samePositionCounter;
363-
else
364-
samePositionCounter = 0;
365-
const isDisplayedAndStable = samePositionCounter >= rafCount;
366-
367-
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
368-
const isVisible = !!style && style.visibility !== 'hidden';
369-
370-
const elementOrButton = element.closest('button, [role=button]') || element;
371-
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
372-
373-
return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false;
359+
let lastRect: types.Rect | undefined;
360+
let counter = 0;
361+
let samePositionCounter = 0;
362+
let lastTime = 0;
363+
return this._pollRaf(progress, (): types.InjectedScriptResult | false => {
364+
// First raf happens in the same animation frame as evaluation, so it does not produce
365+
// any client rect difference compared to synchronous call. We skip the synchronous call
366+
// and only force layout during actual rafs as a small optimisation.
367+
if (++counter === 1)
368+
return false;
369+
if (!node.isConnected)
370+
return { status: 'notconnected' };
371+
372+
// Drop frames that are shorter than 16ms - WebKit Win bug.
373+
const time = performance.now();
374+
if (rafCount > 1 && time - lastTime < 15)
375+
return false;
376+
lastTime = time;
377+
378+
// Note: this logic should be similar to isVisible() to avoid surprises.
379+
const clientRect = element.getBoundingClientRect();
380+
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
381+
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;
382+
lastRect = rect;
383+
if (samePosition)
384+
++samePositionCounter;
385+
else
386+
samePositionCounter = 0;
387+
const isDisplayedAndStable = samePositionCounter >= rafCount;
388+
389+
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
390+
const isVisible = !!style && style.visibility !== 'hidden';
391+
392+
const elementOrButton = element.closest('button, [role=button]') || element;
393+
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
394+
395+
return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false;
396+
});
374397
});
375398
}
376399

@@ -421,6 +444,12 @@ export default class InjectedScript {
421444
}
422445
return element;
423446
}
447+
448+
previewElement(element: Element): string {
449+
const id = element.id ? '#' + element.id : '';
450+
const classes = Array.from(element.classList).map(c => '.' + c).join('');
451+
return `${element.nodeName.toLowerCase()}${id}${classes}`;
452+
}
424453
}
425454

426455
const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'drag'>([

src/progress.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export class Progress {
7474
this._logger = logger;
7575
}
7676

77+
isCanceled(): boolean {
78+
return !this._running;
79+
}
80+
7781
cleanupWhenCanceled(cleanup: () => any) {
7882
if (this._running)
7983
this._cleanups.push(cleanup);

src/selectors.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,42 @@ export class Selectors {
116116
const task = async (context: dom.FrameExecutionContext) => {
117117
const injectedScript = await context.injectedScript();
118118
return injectedScript.evaluateHandle((injected, { parsed, state }) => {
119-
return injected.poll('raf', () => {
119+
let lastElement: Element | undefined;
120+
121+
return injected.poll('raf', (progress: types.InjectedScriptProgress) => {
120122
const element = injected.querySelector(parsed, document);
123+
124+
const log = (suffix: string) => {
125+
if (lastElement === element)
126+
return;
127+
lastElement = element;
128+
if (!element)
129+
progress.log(`selector did not resolve to any element`);
130+
else
131+
progress.log(`selector resolved to "${injected.previewElement(element)}"${suffix ? ' ' + suffix : ''}`);
132+
};
133+
121134
switch (state) {
122-
case 'attached':
135+
case 'attached': {
123136
return element || false;
124-
case 'detached':
137+
}
138+
case 'detached': {
139+
if (element)
140+
log('');
125141
return !element;
126-
case 'visible':
127-
return element && injected.isVisible(element) ? element : false;
128-
case 'hidden':
129-
return !element || !injected.isVisible(element);
142+
}
143+
case 'visible': {
144+
const result = element && injected.isVisible(element) ? element : false;
145+
if (!result)
146+
log('that is not visible');
147+
return result;
148+
}
149+
case 'hidden': {
150+
const result = !element || !injected.isVisible(element);
151+
if (!result)
152+
log('that is still visible');
153+
return result;
154+
}
130155
}
131156
});
132157
}, { parsed, state });

src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,14 @@ export type InjectedScriptResult<T = undefined> =
169169
{ status: 'notconnected' } |
170170
{ status: 'error', error: string };
171171

172-
export type CancelablePoll<T> = {
172+
export type InjectedScriptProgress = {
173+
canceled: boolean,
174+
log: (message: string) => void,
175+
};
176+
177+
export type InjectedScriptLogs = { current: string[], next: Promise<InjectedScriptLogs> };
178+
export type InjectedScriptPoll<T> = {
173179
result: Promise<T>,
180+
logs: Promise<InjectedScriptLogs>,
174181
cancel: () => void,
175182
};

0 commit comments

Comments
 (0)