chore: migrate navigations to Progress (#2463)

This commit is contained in:
Dmitry Gozman 2020-06-04 16:43:48 -07:00 committed by GitHub
parent 724d73c03b
commit 1d37a10558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 344 additions and 297 deletions

View file

@ -28,8 +28,8 @@ import { Page } from './page';
import { selectors } from './selectors'; import { selectors } from './selectors';
import * as types from './types'; import * as types from './types';
import { NotConnectedError } from './errors'; import { NotConnectedError } from './errors';
import { Log, logError } from './logger'; import { logError, apiLog } from './logger';
import { Progress } from './progress'; import { Progress, runAbortableTask } from './progress';
export type PointerActionOptions = { export type PointerActionOptions = {
modifiers?: input.Modifier[]; modifiers?: input.Modifier[];
@ -40,11 +40,6 @@ export type ClickOptions = PointerActionOptions & input.MouseClickOptions;
export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOptions; export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOptions;
export const inputLog: Log = {
name: 'input',
color: 'cyan'
};
export class FrameExecutionContext extends js.ExecutionContext { export class FrameExecutionContext extends js.ExecutionContext {
readonly frame: frames.Frame; readonly frame: frames.Frame;
private _injectedPromise?: Promise<js.JSHandle>; private _injectedPromise?: Promise<js.JSHandle>;
@ -251,7 +246,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> { async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
while (!progress.isCanceled()) { while (progress.isRunning()) {
const result = await this._performPointerAction(progress, action, options); const result = await this._performPointerAction(progress, action, options);
if (result === 'done') if (result === 'done')
return; return;
@ -263,27 +258,27 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (!force) if (!force)
await this._waitForDisplayedAtStablePositionAndEnabled(progress); await this._waitForDisplayedAtStablePositionAndEnabled(progress);
progress.log(inputLog, 'scrolling into view if needed...'); progress.log(apiLog, 'scrolling into view if needed...');
const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
if (scrolled === 'invisible') { if (scrolled === 'invisible') {
if (force) if (force)
throw new Error('Element is not visible'); throw new Error('Element is not visible');
progress.log(inputLog, '...element is not visible, retrying input action'); progress.log(apiLog, '...element is not visible, retrying input action');
return 'retry'; return 'retry';
} }
progress.log(inputLog, '...done scrolling'); progress.log(apiLog, '...done scrolling');
const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint(); const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint();
if (maybePoint === 'invisible') { if (maybePoint === 'invisible') {
if (force) if (force)
throw new Error('Element is not visible'); throw new Error('Element is not visible');
progress.log(inputLog, 'element is not visibile, retrying input action'); progress.log(apiLog, 'element is not visibile, retrying input action');
return 'retry'; return 'retry';
} }
if (maybePoint === 'outsideviewport') { if (maybePoint === 'outsideviewport') {
if (force) if (force)
throw new Error('Element is outside of the viewport'); throw new Error('Element is outside of the viewport');
progress.log(inputLog, 'element is outside of the viewport, retrying input action'); progress.log(apiLog, 'element is outside of the viewport, retrying input action');
return 'retry'; return 'retry';
} }
const point = roundPoint(maybePoint); const point = roundPoint(maybePoint);
@ -291,33 +286,35 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (!force) { if (!force) {
if ((options as any).__testHookBeforeHitTarget) if ((options as any).__testHookBeforeHitTarget)
await (options as any).__testHookBeforeHitTarget(); await (options as any).__testHookBeforeHitTarget();
progress.log(inputLog, `checking that element receives pointer events at (${point.x},${point.y})...`); progress.log(apiLog, `checking that element receives pointer events at (${point.x},${point.y})...`);
const matchesHitTarget = await this._checkHitTargetAt(point); const matchesHitTarget = await this._checkHitTargetAt(point);
if (!matchesHitTarget) { if (!matchesHitTarget) {
progress.log(inputLog, '...element does not receive pointer events, retrying input action'); progress.log(apiLog, '...element does not receive pointer events, retrying input action');
return 'retry'; return 'retry';
} }
progress.log(inputLog, `...element does receive pointer events, continuing input action`); progress.log(apiLog, `...element does receive pointer events, continuing input action`);
} }
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
let restoreModifiers: input.Modifier[] | undefined; let restoreModifiers: input.Modifier[] | undefined;
if (options && options.modifiers) if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
progress.log(inputLog, `performing ${progress.apiName} action...`); progress.log(apiLog, `performing ${progress.apiName} action...`);
await action(point); await action(point);
progress.log(inputLog, `...${progress.apiName} action done`); progress.log(apiLog, `...${progress.apiName} action done`);
progress.log(inputLog, 'waiting for scheduled navigations to finish...'); progress.log(apiLog, 'waiting for scheduled navigations to finish...');
if ((options as any).__testHookAfterPointerAction)
await (options as any).__testHookAfterPointerAction();
if (restoreModifiers) if (restoreModifiers)
await this._page.keyboard._ensureModifiers(restoreModifiers); await this._page.keyboard._ensureModifiers(restoreModifiers);
}, 'input'); }, 'input');
progress.log(inputLog, '...navigations have finished'); progress.log(apiLog, '...navigations have finished');
return 'done'; return 'done';
} }
hover(options: PointerActionOptions & types.PointerActionWaitOptions = {}): Promise<void> { hover(options: PointerActionOptions & types.PointerActionWaitOptions = {}): Promise<void> {
return Progress.runCancelableTask(progress => this._hover(progress, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._hover(progress, options), options, this._page, this._page._timeoutSettings);
} }
_hover(progress: Progress, options: PointerActionOptions & types.PointerActionWaitOptions): Promise<void> { _hover(progress: Progress, options: PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
@ -325,7 +322,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
click(options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> { click(options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return Progress.runCancelableTask(progress => this._click(progress, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._click(progress, options), options, this._page, this._page._timeoutSettings);
} }
_click(progress: Progress, options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> { _click(progress: Progress, options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
@ -333,7 +330,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
dblclick(options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> { dblclick(options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return Progress.runCancelableTask(progress => this._dblclick(progress, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._dblclick(progress, options), options, this._page, this._page._timeoutSettings);
} }
_dblclick(progress: Progress, options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> { _dblclick(progress: Progress, options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
@ -341,11 +338,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise<string[]> { async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise<string[]> {
return Progress.runCancelableTask(progress => this._selectOption(progress, values, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._selectOption(progress, values, options), options, this._page, this._page._timeoutSettings);
} }
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions): Promise<string[]> { async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions): Promise<string[]> {
progress.log(inputLog, progress.apiName); progress.log(apiLog, progress.apiName);
let vals: string[] | ElementHandle[] | types.SelectOption[]; let vals: string[] | ElementHandle[] | types.SelectOption[];
if (!Array.isArray(values)) if (!Array.isArray(values))
vals = [ values ] as (string[] | ElementHandle[] | types.SelectOption[]); vals = [ values ] as (string[] | ElementHandle[] | types.SelectOption[]);
@ -369,11 +366,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> { async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
return Progress.runCancelableTask(progress => this._fill(progress, value, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._fill(progress, value, options), options, this._page, this._page._timeoutSettings);
} }
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<void> { async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<void> {
progress.log(inputLog, `elementHandle.fill("${value}")`); progress.log(apiLog, `elementHandle.fill("${value}")`);
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"'); assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
const poll = await this._evaluateHandleInUtility(([injected, node, value]) => { const poll = await this._evaluateHandleInUtility(([injected, node, value]) => {
@ -392,17 +389,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async selectText(): Promise<void> { async selectText(): Promise<void> {
this._page._log(inputLog, `elementHandle.selectText()`); this._page._log(apiLog, `elementHandle.selectText()`);
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {}); const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {});
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
} }
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) { async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) {
return Progress.runCancelableTask(async progress => this._setInputFiles(progress, files, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(async progress => this._setInputFiles(progress, files, options), options, this._page, this._page._timeoutSettings);
} }
async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions) { async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions) {
progress.log(inputLog, progress.apiName); progress.log(apiLog, progress.apiName);
const injectedResult = await this._evaluateInUtility(([injected, node]): types.InjectedScriptResult<boolean> => { const injectedResult = await this._evaluateInUtility(([injected, node]): types.InjectedScriptResult<boolean> => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT') if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
return { status: 'error', error: 'Node is not an HTMLInputElement' }; return { status: 'error', error: 'Node is not an HTMLInputElement' };
@ -437,17 +434,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async focus() { async focus() {
this._page._log(inputLog, `elementHandle.focus()`); this._page._log(apiLog, `elementHandle.focus()`);
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {}); const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
} }
async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
return Progress.runCancelableTask(progress => this._type(progress, text, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._type(progress, text, options), options, this._page, this._page._timeoutSettings);
} }
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions) { async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
progress.log(inputLog, `elementHandle.type("${text}")`); progress.log(apiLog, `elementHandle.type("${text}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this.focus(); await this.focus();
await this._page.keyboard.type(text, options); await this._page.keyboard.type(text, options);
@ -455,11 +452,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
return Progress.runCancelableTask(progress => this._press(progress, key, options), options, this._page, this._page._timeoutSettings); return runAbortableTask(progress => this._press(progress, key, options), options, this._page, this._page._timeoutSettings);
} }
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions) { async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
progress.log(inputLog, `elementHandle.press("${key}")`); progress.log(apiLog, `elementHandle.press("${key}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this.focus(); await this.focus();
await this._page.keyboard.press(key, options); await this._page.keyboard.press(key, options);
@ -467,15 +464,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
progress.log(inputLog, `elementHandle.check()`); progress.log(apiLog, `elementHandle.check()`);
await this._setChecked(progress, true, options); await this._setChecked(progress, true, options);
}, options, this._page, this._page._timeoutSettings); }, options, this._page, this._page._timeoutSettings);
} }
async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
progress.log(inputLog, `elementHandle.uncheck()`); progress.log(apiLog, `elementHandle.uncheck()`);
await this._setChecked(progress, false, options); await this._setChecked(progress, false, options);
}, options, this._page, this._page._timeoutSettings); }, options, this._page, this._page._timeoutSettings);
} }
@ -525,7 +522,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> { async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> {
progress.log(inputLog, 'waiting for element to be displayed, enabled and not moving...'); progress.log(apiLog, 'waiting for element to be displayed, enabled and not moving...');
const rafCount = this._page._delegate.rafCountForStablePosition(); const rafCount = this._page._delegate.rafCountForStablePosition();
const poll = await this._evaluateHandleInUtility(([injected, node, rafCount]) => { const poll = await this._evaluateHandleInUtility(([injected, node, rafCount]) => {
return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount); return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount);
@ -533,7 +530,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
new InjectedScriptPollHandler(progress, poll); new InjectedScriptPollHandler(progress, poll);
const injectedResult = await poll.evaluate(poll => poll.result); const injectedResult = await poll.evaluate(poll => poll.result);
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
progress.log(inputLog, '...element is displayed and does not move'); progress.log(apiLog, '...element is displayed and does not move');
} }
async _checkHitTargetAt(point: types.Point): Promise<boolean> { async _checkHitTargetAt(point: types.Point): Promise<boolean> {
@ -563,18 +560,18 @@ export class InjectedScriptPollHandler {
constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<any>>) { constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<any>>) {
this._progress = progress; this._progress = progress;
this._poll = poll; this._poll = poll;
this._progress.cleanupWhenCanceled(() => this.cancel()); this._progress.cleanupWhenAborted(() => this.cancel());
this._streamLogs(poll.evaluateHandle(poll => poll.logs)); this._streamLogs(poll.evaluateHandle(poll => poll.logs));
} }
private _streamLogs(logsPromise: Promise<js.JSHandle<types.InjectedScriptLogs>>) { 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. // We continuously get a chunk of logs, stream them to the progress and wait for the next chunk.
logsPromise.catch(e => null).then(logs => { logsPromise.catch(e => null).then(logs => {
if (!logs || !this._poll || this._progress.isCanceled()) if (!logs || !this._poll || !this._progress.isRunning())
return; return;
logs.evaluate(logs => logs.current).catch(e => [] as string[]).then(messages => { logs.evaluate(logs => logs.current).catch(e => [] as string[]).then(messages => {
for (const message of messages) for (const message of messages)
this._progress.log(inputLog, message); this._progress.log(apiLog, message);
}); });
this._streamLogs(logs.evaluateHandle(logs => logs.next)); this._streamLogs(logs.evaluateHandle(logs => logs.next));
}); });

View file

@ -19,7 +19,7 @@ import * as fs from 'fs';
import * as util from 'util'; import * as util from 'util';
import { ConsoleMessage } from './console'; import { ConsoleMessage } from './console';
import * as dom from './dom'; import * as dom from './dom';
import { TimeoutError, NotConnectedError } from './errors'; import { NotConnectedError } from './errors';
import { Events } from './events'; import { Events } from './events';
import { assert, helper, RegisteredListener, assertMaxArguments, debugAssert } from './helper'; import { assert, helper, RegisteredListener, assertMaxArguments, debugAssert } from './helper';
import * as js from './javascript'; import * as js from './javascript';
@ -29,8 +29,8 @@ import { selectors } from './selectors';
import * as types from './types'; import * as types from './types';
import { waitForTimeoutWasUsed } from './hints'; import { waitForTimeoutWasUsed } from './hints';
import { BrowserContext } from './browserContext'; import { BrowserContext } from './browserContext';
import { rewriteErrorMessage } from './debug/stackTrace'; import { Progress, ProgressController, runAbortableTask } from './progress';
import { Progress } from './progress'; import { apiLog } from './logger';
type ContextType = 'main' | 'utility'; type ContextType = 'main' | 'utility';
type ContextData = { type ContextData = {
@ -111,7 +111,7 @@ export class FrameManager {
const barrier = new SignalBarrier(progress); const barrier = new SignalBarrier(progress);
this._signalBarriers.add(barrier); this._signalBarriers.add(barrier);
if (progress) if (progress)
progress.cleanupWhenCanceled(() => this._signalBarriers.delete(barrier)); progress.cleanupWhenAborted(() => this._signalBarriers.delete(barrier));
const result = await action(); const result = await action();
if (source === 'input') if (source === 'input')
await this._page._delegate.inputActionEpilogue(); await this._page._delegate.inputActionEpilogue();
@ -180,26 +180,18 @@ export class FrameManager {
} }
frameStoppedLoading(frameId: string) { frameStoppedLoading(frameId: string) {
const frame = this._frames.get(frameId); this.frameLifecycleEvent(frameId, 'domcontentloaded');
if (!frame) this.frameLifecycleEvent(frameId, 'load');
return;
const hasDOMContentLoaded = frame._firedLifecycleEvents.has('domcontentloaded');
const hasLoad = frame._firedLifecycleEvents.has('load');
frame._firedLifecycleEvents.add('domcontentloaded');
frame._firedLifecycleEvents.add('load');
this._notifyLifecycle(frame);
if (frame === this.mainFrame() && !hasDOMContentLoaded)
this._page.emit(Events.Page.DOMContentLoaded);
if (frame === this.mainFrame() && !hasLoad)
this._page.emit(Events.Page.Load);
} }
frameLifecycleEvent(frameId: string, event: types.LifecycleEvent) { frameLifecycleEvent(frameId: string, event: types.LifecycleEvent) {
const frame = this._frames.get(frameId); const frame = this._frames.get(frameId);
if (!frame) if (!frame)
return; return;
if (frame._firedLifecycleEvents.has(event))
return;
frame._firedLifecycleEvents.add(event); frame._firedLifecycleEvents.add(event);
this._notifyLifecycle(frame); this._notifyLifecycle(frame, event);
if (frame === this._mainFrame && event === 'load') if (frame === this._mainFrame && event === 'load')
this._page.emit(Events.Page.Load); this._page.emit(Events.Page.Load);
if (frame === this._mainFrame && event === 'domcontentloaded') if (frame === this._mainFrame && event === 'domcontentloaded')
@ -261,10 +253,10 @@ export class FrameManager {
task.onNewDocument(documentId, new Error(error)); task.onNewDocument(documentId, new Error(error));
} }
private _notifyLifecycle(frame: Frame) { private _notifyLifecycle(frame: Frame, lifecycleEvent: types.LifecycleEvent) {
for (let parent: Frame | null = frame; parent; parent = parent.parentFrame()) { for (let parent: Frame | null = frame; parent; parent = parent.parentFrame()) {
for (const frameTask of parent._frameTasks) for (const frameTask of parent._frameTasks)
frameTask.onLifecycle(); frameTask.onLifecycle(frame, lifecycleEvent);
} }
} }
@ -350,55 +342,67 @@ export class Frame {
} }
async goto(url: string, options: GotoOptions = {}): Promise<network.Response | null> { async goto(url: string, options: GotoOptions = {}): Promise<network.Response | null> {
const headers = (this._page._state.extraHTTPHeaders || {}); const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
let referer = headers['referer'] || headers['Referer']; abortProgressOnFrameDetach(progressController, this);
if (options.referer !== undefined) { return progressController.run(async progress => {
if (referer !== undefined && referer !== options.referer) progress.log(apiLog, `${progress.apiName}("${url}"), waiting until "${options.waitUntil || 'load'}"`);
throw new Error('"referer" is already specified as extra HTTP header'); const headers = (this._page._state.extraHTTPHeaders || {});
referer = options.referer; let referer = headers['referer'] || headers['Referer'];
} if (options.referer !== undefined) {
url = helper.completeUserURL(url); if (referer !== undefined && referer !== options.referer)
throw new Error('"referer" is already specified as extra HTTP header');
referer = options.referer;
}
url = helper.completeUserURL(url);
const frameTask = new FrameTask(this, options, url); const frameTask = new FrameTask(this, progress);
const sameDocumentPromise = frameTask.waitForSameDocumentNavigation(); const sameDocumentPromise = frameTask.waitForSameDocumentNavigation();
const navigateResult = await frameTask.raceAgainstFailures(this._page._delegate.navigateFrame(this, url, referer)).catch(e => { const navigateResult = await this._page._delegate.navigateFrame(this, url, referer).catch(e => {
// Do not leave sameDocumentPromise unhandled. // Do not leave sameDocumentPromise unhandled.
sameDocumentPromise.catch(e => {}); sameDocumentPromise.catch(e => {});
throw e; throw e;
});
if (navigateResult.newDocumentId) {
// Do not leave sameDocumentPromise unhandled.
sameDocumentPromise.catch(e => {});
await frameTask.waitForSpecificDocument(navigateResult.newDocumentId);
} else {
await sameDocumentPromise;
}
const request = (navigateResult && navigateResult.newDocumentId) ? frameTask.request(navigateResult.newDocumentId) : null;
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
frameTask.done();
return request ? request._finalRequest().response() : null;
}); });
if (navigateResult.newDocumentId) {
// Do not leave sameDocumentPromise unhandled.
sameDocumentPromise.catch(e => {});
await frameTask.waitForSpecificDocument(navigateResult.newDocumentId);
} else {
await sameDocumentPromise;
}
const request = (navigateResult && navigateResult.newDocumentId) ? frameTask.request(navigateResult.newDocumentId) : null;
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
frameTask.done();
return request ? request._finalRequest().response() : null;
} }
async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise<network.Response | null> { async waitForNavigation(options: types.WaitForNavigationOptions = {}): Promise<network.Response | null> {
return this._waitForNavigation(options); const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
} abortProgressOnFrameDetach(progressController, this);
return progressController.run(async progress => {
async _waitForNavigation(options: types.ExtendedWaitForNavigationOptions = {}): Promise<network.Response | null> { const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : '';
const frameTask = new FrameTask(this, options); progress.log(apiLog, `waiting for navigation${toUrl} until "${options.waitUntil || 'load'}"`);
let documentId: string | undefined; const frameTask = new FrameTask(this, progress);
await Promise.race([ let documentId: string | undefined;
frameTask.waitForNewDocument(options.url).then(id => documentId = id), await Promise.race([
frameTask.waitForSameDocumentNavigation(options.url), frameTask.waitForNewDocument(options.url).then(id => documentId = id),
]); frameTask.waitForSameDocumentNavigation(options.url),
const request = documentId ? frameTask.request(documentId) : null; ]);
if (options.waitUntil !== 'commit') const request = documentId ? frameTask.request(documentId) : null;
await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil); await frameTask.waitForLifecycle(options.waitUntil === undefined ? 'load' : options.waitUntil);
frameTask.done(); frameTask.done();
return request ? request._finalRequest().response() : null; return request ? request._finalRequest().response() : null;
});
} }
async waitForLoadState(state: types.LifecycleEvent = 'load', options: types.TimeoutOptions = {}): Promise<void> { async waitForLoadState(state: types.LifecycleEvent = 'load', options: types.TimeoutOptions = {}): Promise<void> {
const frameTask = new FrameTask(this, options); const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
abortProgressOnFrameDetach(progressController, this);
return progressController.run(progress => this._waitForLoadState(progress, state));
}
async _waitForLoadState(progress: Progress, state: types.LifecycleEvent): Promise<void> {
const frameTask = new FrameTask(this, progress);
await frameTask.waitForLifecycle(state); await frameTask.waitForLifecycle(state);
frameTask.done(); frameTask.done();
} }
@ -450,8 +454,8 @@ export class Frame {
if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`Unsupported waitFor option "${state}"`); throw new Error(`Unsupported waitFor option "${state}"`);
const { world, task } = selectors._waitForSelectorTask(selector, state); const { world, task } = selectors._waitForSelectorTask(selector, state);
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
progress.log(dom.inputLog, `Waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}...`); progress.log(apiLog, `Waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}...`);
const result = await this._scheduleRerunnableTask(progress, world, task); const result = await this._scheduleRerunnableTask(progress, world, task);
if (!result.asElement()) { if (!result.asElement()) {
result.dispose(); result.dispose();
@ -470,8 +474,8 @@ 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 task = selectors._dispatchEventTask(selector, type, eventInit || {}); const task = selectors._dispatchEventTask(selector, type, eventInit || {});
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
progress.log(dom.inputLog, `Dispatching "${type}" event on selector "${selector}"...`); progress.log(apiLog, `Dispatching "${type}" event on selector "${selector}"...`);
const result = await this._scheduleRerunnableTask(progress, 'main', task); const result = await this._scheduleRerunnableTask(progress, 'main', task);
result.dispose(); result.dispose();
}, options || {}, this._page, this._page._timeoutSettings); }, options || {}, this._page, this._page._timeoutSettings);
@ -515,24 +519,31 @@ export class Frame {
}); });
} }
async setContent(html: string, options?: types.NavigateOptions): Promise<void> { async setContent(html: string, options: types.NavigateOptions = {}): Promise<void> {
const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`; const progressController = new ProgressController(options, this._page, this._page._timeoutSettings.navigationTimeout());
const context = await this._utilityContext(); abortProgressOnFrameDetach(progressController, this);
const lifecyclePromise = new Promise((resolve, reject) => { return progressController.run(async progress => {
this._page._frameManager._consoleMessageTags.set(tag, () => { const waitUntil = options.waitUntil === undefined ? 'load' : options.waitUntil;
// Clear lifecycle right after document.open() - see 'tag' below. progress.log(apiLog, `${progress.apiName}(), waiting until "${waitUntil}"`);
this._page._frameManager.clearFrameLifecycle(this); const tag = `--playwright--set--content--${this._id}--${++this._setContentCounter}--`;
this.waitForLoadState(options ? options.waitUntil : 'load', options).then(resolve).catch(reject); const context = await this._utilityContext();
const lifecyclePromise = new Promise((resolve, reject) => {
this._page._frameManager._consoleMessageTags.set(tag, () => {
progress.log(apiLog, 'content written');
// Clear lifecycle right after document.open() - see 'tag' below.
this._page._frameManager.clearFrameLifecycle(this);
this._waitForLoadState(progress, waitUntil).then(resolve).catch(reject);
});
}); });
const contentPromise = context.evaluateInternal(({ html, tag }) => {
window.stop();
document.open();
console.debug(tag); // eslint-disable-line no-console
document.write(html);
document.close();
}, { html, tag });
await Promise.all([contentPromise, lifecyclePromise]);
}); });
const contentPromise = context.evaluateInternal(({ html, tag }) => {
window.stop();
document.open();
console.debug(tag); // eslint-disable-line no-console
document.write(html);
document.close();
}, { html, tag });
await Promise.all([contentPromise, lifecyclePromise]);
} }
name(): string { name(): string {
@ -698,23 +709,23 @@ export class Frame {
private async _retryWithSelectorIfNotConnected<R>( private async _retryWithSelectorIfNotConnected<R>(
selector: string, options: types.TimeoutOptions, selector: string, options: types.TimeoutOptions,
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R>): Promise<R> { action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R>): Promise<R> {
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
progress.log(dom.inputLog, `${progress.apiName}("${selector}")`); progress.log(apiLog, `${progress.apiName}("${selector}")`);
while (!progress.isCanceled()) { while (progress.isRunning()) {
try { try {
const { world, task } = selectors._waitForSelectorTask(selector, 'attached'); const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
progress.log(dom.inputLog, `waiting for the selector "${selector}"`); progress.log(apiLog, `waiting for the selector "${selector}"`);
const handle = await this._scheduleRerunnableTask(progress, world, task); const handle = await this._scheduleRerunnableTask(progress, world, task);
progress.log(dom.inputLog, `...got element for the selector`); progress.log(apiLog, `...got element for the selector`);
const element = handle.asElement() as dom.ElementHandle<Element>; const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenCanceled(() => element.dispose()); progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element); const result = await action(progress, element);
element.dispose(); element.dispose();
return result; return result;
} catch (e) { } catch (e) {
if (!(e instanceof NotConnectedError)) if (!(e instanceof NotConnectedError))
throw e; throw e;
progress.log(dom.inputLog, 'element was detached from the DOM, retrying'); progress.log(apiLog, 'element was detached from the DOM, retrying');
} }
} }
return undefined as any; return undefined as any;
@ -804,7 +815,7 @@ export class Frame {
return injectedScript.poll(polling, () => innerPredicate(arg)); return injectedScript.poll(polling, () => innerPredicate(arg));
}, { injectedScript, predicateBody, polling, arg }); }, { injectedScript, predicateBody, polling, arg });
}; };
return Progress.runCancelableTask( return runAbortableTask(
progress => this._scheduleRerunnableTask(progress, 'main', task), progress => this._scheduleRerunnableTask(progress, 'main', task),
options, this._page, this._page._timeoutSettings); options, this._page, this._page._timeoutSettings);
} }
@ -892,11 +903,11 @@ class RerunnableTask<T> {
this._task = task; this._task = task;
this._progress = progress; this._progress = progress;
data.rerunnableTasks.add(this); data.rerunnableTasks.add(this);
this.promise = progress.race(new Promise<types.SmartHandle<T>>((resolve, reject) => { this.promise = new Promise<types.SmartHandle<T>>((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;
})); });
} }
terminate(error: Error) { terminate(error: Error) {
@ -948,8 +959,14 @@ export class SignalBarrier {
async addFrameNavigation(frame: Frame) { async addFrameNavigation(frame: Frame) {
this.retain(); this.retain();
const timeout = this._progress ? helper.timeUntilDeadline(this._progress.deadline) : undefined; const frameTask = new FrameTask(frame, this._progress);
await frame._waitForNavigation({timeout, waitUntil: 'commit'}).catch(e => {}); await Promise.race([
frame._page._disconnectedPromise,
frame._detachedPromise,
frameTask.waitForNewDocument(),
frameTask.waitForSameDocumentNavigation(),
]).catch(e => {});
frameTask.done();
this.release(); this.release();
} }
@ -970,36 +987,32 @@ export class SignalBarrier {
export class FrameTask { export class FrameTask {
private _frame: Frame; private _frame: Frame;
private _failurePromise: Promise<Error>;
private _requestMap = new Map<string, network.Request>(); private _requestMap = new Map<string, network.Request>();
private _timer?: NodeJS.Timer; private readonly _progress: Progress | null = null;
private _url: string | undefined;
onNewDocument: (documentId: string, error?: Error) => void = () => {}; onNewDocument: (documentId: string, error?: Error) => void;
onSameDocument = () => {}; onSameDocument: (documentId?: string, error?: Error) => void;
onLifecycle = () => {}; onLifecycle: (frame: Frame, lifecycleEvent?: types.LifecycleEvent) => void;
constructor(frame: Frame, options: types.TimeoutOptions, url?: string) { constructor(frame: Frame, progress: Progress | null) {
this._frame = frame; this._frame = frame;
this._url = url;
// Process timeouts
let timeoutPromise = new Promise<TimeoutError>(() => {});
const { timeout = frame._page._timeoutSettings.navigationTimeout() } = options;
if (timeout) {
const errorMessage = 'Navigation timeout exceeded';
timeoutPromise = new Promise(fulfill => this._timer = setTimeout(fulfill, timeout))
.then(() => { throw new TimeoutError(errorMessage); });
}
// Process detached frames
this._failurePromise = Promise.race([
timeoutPromise,
this._frame._page._disconnectedPromise.then(() => { throw new Error('Navigation failed because browser has disconnected!'); }),
this._frame._detachedPromise.then(() => { throw new Error('Navigating frame was detached!'); }),
]);
frame._frameTasks.add(this); frame._frameTasks.add(this);
this._progress = progress;
this.onSameDocument = this._logUrl.bind(this);
this.onNewDocument = this._logUrl.bind(this);
this.onLifecycle = this._logLifecycle.bind(this);
if (progress)
progress.cleanupWhenAborted(() => this.done());
}
private _logUrl(documentId?: string, error?: Error) {
if (this._progress && !error)
this._progress.log(apiLog, `navigated to "${this._frame._url}"`);
}
private _logLifecycle(frame: Frame, lifecycleEvent?: types.LifecycleEvent) {
if (this._progress && frame === this._frame && lifecycleEvent && frame._url !== 'about:blank')
this._progress.log(apiLog, `"${lifecycleEvent}" event fired`);
} }
onRequest(request: network.Request) { onRequest(request: network.Request) {
@ -1008,38 +1021,24 @@ export class FrameTask {
this._requestMap.set(request._documentId, request); this._requestMap.set(request._documentId, request);
} }
async raceAgainstFailures<T>(promise: Promise<T>): Promise<T> {
let result: T;
let error: Error | undefined;
await Promise.race([
this._failurePromise.catch(e => error = e),
promise.then(r => result = r).catch(e => error = e)
]);
if (!error)
return result!;
this.done();
if (this._url)
rewriteErrorMessage(error, error.message + ` while navigating to ${this._url}`);
throw error;
}
request(documentId: string): network.Request | undefined { request(documentId: string): network.Request | undefined {
return this._requestMap.get(documentId); return this._requestMap.get(documentId);
} }
waitForSameDocumentNavigation(url?: types.URLMatch): Promise<void> { waitForSameDocumentNavigation(url?: types.URLMatch): Promise<void> {
return this.raceAgainstFailures(new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.onSameDocument = () => { this.onSameDocument = () => {
this._logUrl();
if (helper.urlMatches(this._frame.url(), url)) if (helper.urlMatches(this._frame.url(), url))
resolve(); resolve();
}; };
})); });
} }
waitForSpecificDocument(expectedDocumentId: string): Promise<void> { waitForSpecificDocument(expectedDocumentId: string): Promise<void> {
return this.raceAgainstFailures(new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.onNewDocument = (documentId: string, error?: Error) => { this.onNewDocument = (documentId: string, error?: Error) => {
this._logUrl(documentId, error);
if (documentId === expectedDocumentId) { if (documentId === expectedDocumentId) {
if (!error) if (!error)
resolve(); resolve();
@ -1049,12 +1048,13 @@ export class FrameTask {
reject(new Error('Navigation interrupted by another one')); reject(new Error('Navigation interrupted by another one'));
} }
}; };
})); });
} }
waitForNewDocument(url?: types.URLMatch): Promise<string> { waitForNewDocument(url?: types.URLMatch): Promise<string> {
return this.raceAgainstFailures(new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.onNewDocument = (documentId: string, error?: Error) => { this.onNewDocument = (documentId: string, error?: Error) => {
this._logUrl(documentId, error);
if (!error && !helper.urlMatches(this._frame.url(), url)) if (!error && !helper.urlMatches(this._frame.url(), url))
return; return;
if (error) if (error)
@ -1062,7 +1062,7 @@ export class FrameTask {
else else
resolve(documentId); resolve(documentId);
}; };
})); });
} }
waitForLifecycle(waitUntil: types.LifecycleEvent): Promise<void> { waitForLifecycle(waitUntil: types.LifecycleEvent): Promise<void> {
@ -1070,14 +1070,15 @@ export class FrameTask {
waitUntil = 'networkidle'; waitUntil = 'networkidle';
if (!types.kLifecycleEvents.has(waitUntil)) if (!types.kLifecycleEvents.has(waitUntil))
throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`); throw new Error(`Unsupported waitUntil option ${String(waitUntil)}`);
return this.raceAgainstFailures(new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.onLifecycle = () => { this.onLifecycle = (frame: Frame, lifecycleEvent?: types.LifecycleEvent) => {
this._logLifecycle(frame, lifecycleEvent);
if (!checkLifecycleRecursively(this._frame)) if (!checkLifecycleRecursively(this._frame))
return; return;
resolve(); resolve();
}; };
this.onLifecycle(); this.onLifecycle(this._frame);
})); });
function checkLifecycleRecursively(frame: Frame): boolean { function checkLifecycleRecursively(frame: Frame): boolean {
if (!frame._firedLifecycleEvents.has(waitUntil)) if (!frame._firedLifecycleEvents.has(waitUntil))
@ -1092,8 +1093,10 @@ export class FrameTask {
done() { done() {
this._frame._frameTasks.delete(this); this._frame._frameTasks.delete(this);
if (this._timer)
clearTimeout(this._timer);
this._failurePromise.catch(e => {});
} }
} }
function abortProgressOnFrameDetach(controller: ProgressController, frame: Frame) {
frame._page._disconnectedPromise.then(() => controller.abort(new Error('Navigation failed because browser has disconnected!')));
frame._detachedPromise.then(() => controller.abort(new Error('Navigating frame was detached!')));
}

View file

@ -35,6 +35,7 @@ export interface InnerLogger {
} }
export const errorLog: Log = { name: 'generic', severity: 'error' }; export const errorLog: Log = { name: 'generic', severity: 'error' };
export const apiLog: Log = { name: 'api', color: 'cyan' };
export function logError(logger: InnerLogger): (error: Error) => void { export function logError(logger: InnerLogger): (error: Error) => void {
return error => logger._log(errorLog, error, []); return error => logger._log(errorLog, error, []);

View file

@ -16,97 +16,109 @@
import { InnerLogger, Log } from './logger'; import { InnerLogger, Log } from './logger';
import { TimeoutError } from './errors'; import { TimeoutError } from './errors';
import { helper } from './helper'; import { helper, assert } from './helper';
import * as types from './types'; import * as types from './types';
import { DEFAULT_TIMEOUT, TimeoutSettings } from './timeoutSettings'; import { DEFAULT_TIMEOUT, TimeoutSettings } from './timeoutSettings';
import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace'; import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace';
class AbortError extends Error {} export interface Progress {
readonly apiName: string;
readonly deadline: number; // To be removed?
readonly aborted: Promise<void>;
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
log(log: Log, message: string | Error): void;
}
export class Progress { export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettingsOrDefaultTimeout?: TimeoutSettings | number, apiName?: string): Promise<T> {
static async runCancelableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettings?: TimeoutSettings, apiName?: string): Promise<T> { const controller = new ProgressController(timeoutOptions, logger, timeoutSettingsOrDefaultTimeout, apiName);
apiName = apiName || getCurrentApiCall(); return controller.run(task);
}
const defaultTimeout = timeoutSettings ? timeoutSettings.timeout() : DEFAULT_TIMEOUT; export class ProgressController {
// Promise and callback that forcefully abort the progress.
// This promise always rejects.
private _forceAbort: (error: Error) => void = () => {};
private _forceAbortPromise: Promise<any>;
// Promise and callback that resolve once the progress is aborted.
// This includes the force abort and also rejection of the task itself (failure).
private _aborted = () => {};
private _abortedPromise: Promise<void>;
// Cleanups to be run only in the case of abort.
private _cleanups: (() => any)[] = [];
private _logger: InnerLogger;
private _logRecording: string[] = [];
private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before';
private _apiName: string;
private _deadline: number;
private _timeout: number;
constructor(timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettingsOrDefaultTimeout?: TimeoutSettings | number, apiName?: string) {
this._apiName = apiName || getCurrentApiCall();
this._logger = logger;
// TODO: figure out nice timeout parameters.
let defaultTimeout = DEFAULT_TIMEOUT;
if (typeof timeoutSettingsOrDefaultTimeout === 'number')
defaultTimeout = timeoutSettingsOrDefaultTimeout;
if (timeoutSettingsOrDefaultTimeout instanceof TimeoutSettings)
defaultTimeout = timeoutSettingsOrDefaultTimeout.timeout();
const { timeout = defaultTimeout } = timeoutOptions; const { timeout = defaultTimeout } = timeoutOptions;
const deadline = TimeoutSettings.computeDeadline(timeout); this._timeout = timeout;
this._deadline = TimeoutSettings.computeDeadline(timeout);
let rejectCancelPromise: (error: Error) => void = () => {}; this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
const cancelPromise = new Promise<T>((resolve, x) => rejectCancelPromise = x); this._forceAbortPromise.catch(e => null); // Prevent unhandle promsie rejection.
const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded during ${apiName}.`); this._abortedPromise = new Promise(resolve => this._aborted = resolve);
const timer = setTimeout(() => rejectCancelPromise(timeoutError), helper.timeUntilDeadline(deadline)); }
let resolveCancelation = () => {}; async run<T>(task: (progress: Progress) => Promise<T>): Promise<T> {
const progress = new Progress(deadline, logger, new Promise(resolve => resolveCancelation = resolve), rejectCancelPromise, apiName); assert(this._state === 'before');
this._state = 'running';
const progress: Progress = {
apiName: this._apiName,
deadline: this._deadline,
aborted: this._abortedPromise,
isRunning: () => this._state === 'running',
cleanupWhenAborted: (cleanup: () => any) => {
if (this._state === 'running')
this._cleanups.push(cleanup);
else
runCleanup(cleanup);
},
log: (log: Log, message: string | Error) => {
if (this._state === 'running')
this._logRecording.push(message.toString());
this._logger._log(log, message);
},
};
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded during ${this._apiName}.`);
const timer = setTimeout(() => this._forceAbort(timeoutError), helper.timeUntilDeadline(this._deadline));
try { try {
const promise = task(progress); const promise = task(progress);
const result = await Promise.race([promise, cancelPromise]); const result = await Promise.race([promise, this._forceAbortPromise]);
clearTimeout(timer); clearTimeout(timer);
progress._running = false; this._state = 'finished';
progress._logRecording = []; this._logRecording = [];
return result; return result;
} catch (e) { } catch (e) {
resolveCancelation(); this._aborted();
rewriteErrorMessage(e, e.message + formatLogRecording(progress._logRecording, apiName)); rewriteErrorMessage(e, e.message + formatLogRecording(this._logRecording, this._apiName));
clearTimeout(timer); clearTimeout(timer);
progress._running = false; this._state = 'aborted';
progress._logRecording = []; this._logRecording = [];
await Promise.all(progress._cleanups.splice(0).map(cleanup => runCleanup(cleanup))); await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
throw e; throw e;
} }
} }
readonly apiName: string; abort(error: Error) {
readonly deadline: number; // To be removed? this._forceAbort(error);
readonly cancel: (error: Error) => void;
readonly _canceled: Promise<any>;
private _logger: InnerLogger;
private _logRecording: string[] = [];
private _cleanups: (() => any)[] = [];
private _running = true;
constructor(deadline: number, logger: InnerLogger, canceled: Promise<any>, cancel: (error: Error) => void, apiName: string) {
this.deadline = deadline;
this.apiName = apiName;
this.cancel = cancel;
this._canceled = canceled;
this._logger = logger;
}
isCanceled(): boolean {
return !this._running;
}
cleanupWhenCanceled(cleanup: () => any) {
if (this._running)
this._cleanups.push(cleanup);
else
runCleanup(cleanup);
}
throwIfCanceled() {
if (!this._running)
throw new AbortError();
}
race<T>(promise: Promise<T>, cleanup?: () => any): Promise<T> {
const canceled = this._canceled.then(async error => {
if (cleanup)
await runCleanup(cleanup);
throw error;
});
const success = promise.then(result => {
cleanup = undefined;
return result;
});
return Promise.race<T>([success, canceled]);
}
log(log: Log, message: string | Error): void {
if (this._running)
this._logRecording.push(message.toString());
this._logger._log(log, message);
} }
} }

View file

@ -28,7 +28,7 @@ import { assert } from '../helper';
import { launchProcess, Env, waitForLine } from './processLauncher'; import { launchProcess, Env, waitForLine } from './processLauncher';
import { Events } from '../events'; import { Events } from '../events';
import { PipeTransport } from './pipeTransport'; import { PipeTransport } from './pipeTransport';
import { Progress } from '../progress'; import { Progress, runAbortableTask } from '../progress';
export type BrowserArgOptions = { export type BrowserArgOptions = {
headless?: boolean, headless?: boolean,
@ -107,7 +107,7 @@ export abstract class BrowserTypeBase implements BrowserType {
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead'); assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
const logger = new RootLogger(options.logger); const logger = new RootLogger(options.logger);
const browser = await Progress.runCancelableTask(progress => this._innerLaunch(progress, options, logger, undefined), options, logger); const browser = await runAbortableTask(progress => this._innerLaunch(progress, options, logger, undefined), options, logger);
return browser; return browser;
} }
@ -115,7 +115,7 @@ export abstract class BrowserTypeBase implements BrowserType {
assert(!(options as any).port, 'Cannot specify a port without launching as a server.'); assert(!(options as any).port, 'Cannot specify a port without launching as a server.');
const persistent = validatePersistentContextOptions(options); const persistent = validatePersistentContextOptions(options);
const logger = new RootLogger(options.logger); const logger = new RootLogger(options.logger);
const browser = await Progress.runCancelableTask(progress => this._innerLaunch(progress, options, logger, persistent, userDataDir), options, logger); const browser = await runAbortableTask(progress => this._innerLaunch(progress, options, logger, persistent, userDataDir), options, logger);
return browser._defaultContext!; return browser._defaultContext!;
} }
@ -144,7 +144,7 @@ export abstract class BrowserTypeBase implements BrowserType {
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launchServer`. Use `browserType.launchPersistentContext` instead'); assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launchServer`. Use `browserType.launchPersistentContext` instead');
const { port = 0 } = options; const { port = 0 } = options;
const logger = new RootLogger(options.logger); const logger = new RootLogger(options.logger);
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
const { browserServer, transport } = await this._launchServer(progress, options, false, logger); const { browserServer, transport } = await this._launchServer(progress, options, false, logger);
browserServer._webSocketWrapper = this._wrapTransportWithWebSocket(transport, logger, port); browserServer._webSocketWrapper = this._wrapTransportWithWebSocket(transport, logger, port);
return browserServer; return browserServer;
@ -153,9 +153,9 @@ export abstract class BrowserTypeBase implements BrowserType {
async connect(options: ConnectOptions): Promise<Browser> { async connect(options: ConnectOptions): Promise<Browser> {
const logger = new RootLogger(options.logger); const logger = new RootLogger(options.logger);
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
const transport = await WebSocketTransport.connect(progress, options.wsEndpoint); const transport = await WebSocketTransport.connect(progress, options.wsEndpoint);
progress.cleanupWhenCanceled(() => transport.closeAndWait()); progress.cleanupWhenAborted(() => transport.closeAndWait());
if ((options as any).__testHookBeforeCreateBrowser) if ((options as any).__testHookBeforeCreateBrowser)
await (options as any).__testHookBeforeCreateBrowser(); await (options as any).__testHookBeforeCreateBrowser();
const browser = await this._connectToTransport(transport, { slowMo: options.slowMo, logger }); const browser = await this._connectToTransport(transport, { slowMo: options.slowMo, logger });
@ -221,7 +221,7 @@ export abstract class BrowserTypeBase implements BrowserType {
}, },
}); });
browserServer = new BrowserServer(launchedProcess, gracefullyClose, kill); browserServer = new BrowserServer(launchedProcess, gracefullyClose, kill);
progress.cleanupWhenCanceled(() => browserServer && browserServer._closeOrKill(progress.deadline)); progress.cleanupWhenAborted(() => browserServer && browserServer._closeOrKill(progress.deadline));
if (this._webSocketNotPipe) { if (this._webSocketNotPipe) {
const match = await waitForLine(progress, launchedProcess, this._webSocketNotPipe.stream === 'stdout' ? launchedProcess.stdout : launchedProcess.stderr, this._webSocketNotPipe.webSocketRegex); const match = await waitForLine(progress, launchedProcess, this._webSocketNotPipe.stream === 'stdout' ? launchedProcess.stdout : launchedProcess.stderr, this._webSocketNotPipe.webSocketRegex);

View file

@ -30,7 +30,7 @@ import { BrowserServer } from './browserServer';
import { launchProcess, waitForLine } from './processLauncher'; import { launchProcess, waitForLine } from './processLauncher';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import type {BrowserWindow} from 'electron'; import type {BrowserWindow} from 'electron';
import { Progress } from '../progress'; import { runAbortableTask } from '../progress';
type ElectronLaunchOptions = { type ElectronLaunchOptions = {
args?: string[], args?: string[],
@ -168,7 +168,7 @@ export class Electron {
handleSIGHUP = true, handleSIGHUP = true,
} = options; } = options;
const logger = new RootLogger(options.logger); const logger = new RootLogger(options.logger);
return Progress.runCancelableTask(async progress => { return runAbortableTask(async progress => {
let app: ElectronApplication | undefined = undefined; let app: ElectronApplication | undefined = undefined;
const electronArguments = ['--inspect=0', '--remote-debugging-port=0', '--require', path.join(__dirname, 'electronLoader.js'), ...args]; const electronArguments = ['--inspect=0', '--remote-debugging-port=0', '--require', path.join(__dirname, 'electronLoader.js'), ...args];
const { launchedProcess, gracefullyClose, kill } = await launchProcess({ const { launchedProcess, gracefullyClose, kill } = await launchProcess({

View file

@ -194,7 +194,7 @@ export function waitForLine(progress: Progress, process: childProcess.ChildProce
helper.addEventListener(process, 'error', reject) helper.addEventListener(process, 'error', reject)
]; ];
progress.cleanupWhenCanceled(cleanup); progress.cleanupWhenAborted(cleanup);
function onLine(line: string) { function onLine(line: string) {
const match = line.match(regex); const match = line.match(regex);

View file

@ -127,10 +127,15 @@ export class WebSocketTransport implements ConnectionTransport {
onmessage?: (message: ProtocolResponse) => void; onmessage?: (message: ProtocolResponse) => void;
onclose?: () => void; onclose?: () => void;
static connect(progress: Progress, url: string): Promise<WebSocketTransport> { static async connect(progress: Progress, url: string): Promise<WebSocketTransport> {
progress.log(browserLog, `<ws connecting> ${url}`); progress.log(browserLog, `<ws connecting> ${url}`);
const transport = new WebSocketTransport(progress, url); const transport = new WebSocketTransport(progress, url);
const promise = new Promise<WebSocketTransport>((fulfill, reject) => { let success = false;
progress.aborted.then(() => {
if (!success)
transport.closeAndWait().catch(e => null);
});
await new Promise<WebSocketTransport>((fulfill, reject) => {
transport._ws.addEventListener('open', async () => { transport._ws.addEventListener('open', async () => {
progress.log(browserLog, `<ws connected> ${url}`); progress.log(browserLog, `<ws connected> ${url}`);
fulfill(transport); fulfill(transport);
@ -141,7 +146,8 @@ export class WebSocketTransport implements ConnectionTransport {
transport._ws.close(); transport._ws.close();
}); });
}); });
return progress.race(promise, () => transport.closeAndWait()); success = true;
return transport;
} }
constructor(progress: Progress, url: string) { constructor(progress: Progress, url: string) {

View file

@ -65,11 +65,6 @@ export type WaitForNavigationOptions = TimeoutOptions & {
url?: URLMatch url?: URLMatch
}; };
export type ExtendedWaitForNavigationOptions = TimeoutOptions & {
waitUntil?: LifecycleEvent | 'commit',
url?: URLMatch
};
export type ElementScreenshotOptions = { export type ElementScreenshotOptions = {
type?: 'png' | 'jpeg', type?: 'png' | 'jpeg',
path?: string, path?: string,

View file

@ -0,0 +1 @@
<iframe src='./redirect-my-parent.html'></iframe>

View file

@ -0,0 +1,3 @@
<script>
window.parent.location = './one-frame.html';
</script>

View file

@ -187,6 +187,14 @@ describe('Auto waiting', () => {
await page.click('input[type=submit]'); await page.click('input[type=submit]');
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
}); });
it('should report navigation in the log when clicking anchor', async({page, server}) => {
await page.setContent(`<a href="${server.PREFIX + '/frames/one-frame.html'}">click me</a>`);
const __testHookAfterPointerAction = () => new Promise(f => setTimeout(f, 6000));
const error = await page.click('a', { timeout: 5000, __testHookAfterPointerAction }).catch(e => e);
expect(error.message).toContain('Timeout 5000ms exceeded during page.click.');
expect(error.message).toContain('waiting for scheduled navigations to finish...');
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`);
});
}); });
describe('Auto waiting should not hang when', () => { describe('Auto waiting should not hang when', () => {

View file

@ -198,8 +198,8 @@ describe('Page.goto', function() {
server.setRoute('/empty.html', (req, res) => { }); server.setRoute('/empty.html', (req, res) => { });
let error = null; let error = null;
await page.goto(server.PREFIX + '/empty.html', {timeout: 1}).catch(e => error = e); await page.goto(server.PREFIX + '/empty.html', {timeout: 1}).catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should fail when exceeding default maximum navigation timeout', async({page, server}) => { it('should fail when exceeding default maximum navigation timeout', async({page, server}) => {
@ -209,8 +209,8 @@ describe('Page.goto', function() {
page.context().setDefaultNavigationTimeout(2); page.context().setDefaultNavigationTimeout(2);
page.setDefaultNavigationTimeout(1); page.setDefaultNavigationTimeout(1);
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e); await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should fail when exceeding browser context navigation timeout', async({page, server}) => { it('should fail when exceeding browser context navigation timeout', async({page, server}) => {
@ -219,8 +219,8 @@ describe('Page.goto', function() {
let error = null; let error = null;
page.context().setDefaultNavigationTimeout(2); page.context().setDefaultNavigationTimeout(2);
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e); await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 2ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should fail when exceeding default maximum timeout', async({page, server}) => { it('should fail when exceeding default maximum timeout', async({page, server}) => {
@ -230,8 +230,8 @@ describe('Page.goto', function() {
page.context().setDefaultTimeout(2); page.context().setDefaultTimeout(2);
page.setDefaultTimeout(1); page.setDefaultTimeout(1);
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e); await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should fail when exceeding browser context timeout', async({page, server}) => { it('should fail when exceeding browser context timeout', async({page, server}) => {
@ -240,8 +240,8 @@ describe('Page.goto', function() {
let error = null; let error = null;
page.context().setDefaultTimeout(2); page.context().setDefaultTimeout(2);
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e); await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 2ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should prioritize default navigation timeout over default timeout', async({page, server}) => { it('should prioritize default navigation timeout over default timeout', async({page, server}) => {
@ -251,8 +251,8 @@ describe('Page.goto', function() {
page.setDefaultTimeout(0); page.setDefaultTimeout(0);
page.setDefaultNavigationTimeout(1); page.setDefaultNavigationTimeout(1);
await page.goto(server.PREFIX + '/empty.html').catch(e => error = e); await page.goto(server.PREFIX + '/empty.html').catch(e => error = e);
const message = 'Navigation timeout exceeded'; expect(error.message).toContain('Timeout 1ms exceeded during page.goto.');
expect(error.message).toContain(message); expect(error.message).toContain(server.PREFIX + '/empty.html');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should disable timeout when its set to 0', async({page, server}) => { it('should disable timeout when its set to 0', async({page, server}) => {
@ -365,7 +365,8 @@ describe('Page.goto', function() {
await page.goto(server.PREFIX + '/grid.html', { await page.goto(server.PREFIX + '/grid.html', {
referer: 'http://google.com/', referer: 'http://google.com/',
}).catch(e => error = e); }).catch(e => error = e);
expect(error.message).toBe('"referer" is already specified as extra HTTP header'); expect(error.message).toContain('"referer" is already specified as extra HTTP header');
expect(error.message).toContain(server.PREFIX + '/grid.html');
}); });
it('should override referrer-policy', async({page, server}) => { it('should override referrer-policy', async({page, server}) => {
server.setRoute('/grid.html', (req, res) => { server.setRoute('/grid.html', (req, res) => {
@ -545,6 +546,15 @@ describe('Page.waitForNavigation', function() {
expect(response.ok()).toBe(true); expect(response.ok()).toBe(true);
expect(response.url()).toContain('grid.html'); expect(response.url()).toContain('grid.html');
}); });
it('should respect timeout', async({page, server}) => {
const promise = page.waitForNavigation({ url: '**/frame.html', timeout: 5000 });
await page.goto(server.EMPTY_PAGE);
const error = await promise.catch(e => e);
expect(error.message).toContain('Timeout 5000ms exceeded during page.waitForNavigation.');
expect(error.message).toContain('waiting for navigation to "**/frame.html" until "load"');
expect(error.message).toContain(`navigated to "${server.EMPTY_PAGE}"`);
expect(error.message).toContain(`"load" event fired`);
});
it('should work with both domcontentloaded and load', async({page, server}) => { it('should work with both domcontentloaded and load', async({page, server}) => {
let response = null; let response = null;
server.setRoute('/one-style.css', (req, res) => response = res); server.setRoute('/one-style.css', (req, res) => response = res);
@ -737,7 +747,7 @@ describe('Page.waitForLoadState', () => {
server.setRoute('/one-style.css', (req, res) => response = res); server.setRoute('/one-style.css', (req, res) => response = res);
await page.goto(server.PREFIX + '/one-style.html', {waitUntil: 'domcontentloaded'}); await page.goto(server.PREFIX + '/one-style.html', {waitUntil: 'domcontentloaded'});
const error = await page.waitForLoadState('load', { timeout: 1 }).catch(e => e); const error = await page.waitForLoadState('load', { timeout: 1 }).catch(e => e);
expect(error.message).toBe('Navigation timeout exceeded'); expect(error.message).toContain('Timeout 1ms exceeded during page.waitForLoadState.');
}); });
it('should resolve immediately if loaded', async({page, server}) => { it('should resolve immediately if loaded', async({page, server}) => {
await page.goto(server.PREFIX + '/one-style.html'); await page.goto(server.PREFIX + '/one-style.html');
@ -904,6 +914,16 @@ describe('Frame.goto', function() {
const error = await navigationPromise; const error = await navigationPromise;
expect(error.message).toContain('frame was detached'); expect(error.message).toContain('frame was detached');
}); });
it('should continue after client redirect', async({page, server}) => {
server.setRoute('/frames/script.js', () => {});
const url = server.PREFIX + '/frames/child-redirect.html';
const error = await page.goto(url, { timeout: 5000, waitUntil: 'networkidle' }).catch(e => e);
expect(error.message).toContain('Timeout 5000ms exceeded during page.goto.');
expect(error.message).toContain(`page.goto("${url}"), waiting until "networkidle"`);
expect(error.message).toContain(`navigated to "${url}"`);
expect(error.message).toContain(`navigated to "${server.PREFIX + '/frames/one-frame.html'}"`);
expect(error.message).toContain(`"domcontentloaded" event fired`);
});
it('should return matching responses', async({page, server}) => { it('should return matching responses', async({page, server}) => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
// Attach three frames. // Attach three frames.
@ -955,11 +975,12 @@ describe('Frame.waitForNavigation', function() {
frame.evaluate('window.location = "/empty.html"'), frame.evaluate('window.location = "/empty.html"'),
page.evaluate('setTimeout(() => document.querySelector("iframe").remove())'), page.evaluate('setTimeout(() => document.querySelector("iframe").remove())'),
]).catch(e => error = e); ]).catch(e => error = e);
expect(error.message).toContain('waiting for navigation until "load"');
expect(error.message).toContain('frame was detached'); expect(error.message).toContain('frame was detached');
}); });
}); });
describe('Frame._waitForLodState', function() { describe('Frame.waitForLoadState', function() {
it('should work', async({page, server}) => { it('should work', async({page, server}) => {
await page.goto(server.PREFIX + '/frames/one-frame.html'); await page.goto(server.PREFIX + '/frames/one-frame.html');
const frame = page.frames()[1]; const frame = page.frames()[1];

View file

@ -631,8 +631,8 @@ describe('Page.setContent', function() {
const imgPath = '/img.png'; const imgPath = '/img.png';
// stall for image // stall for image
server.setRoute(imgPath, (req, res) => {}); server.setRoute(imgPath, (req, res) => {});
let error = null; const error = await page.setContent(`<img src="${server.PREFIX + imgPath}"></img>`).catch(e => e);
await page.setContent(`<img src="${server.PREFIX + imgPath}"></img>`).catch(e => error = e); expect(error.message).toContain('Timeout 1ms exceeded during page.setContent.');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError); expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
}); });
it('should await resources to load', async({page, server}) => { it('should await resources to load', async({page, server}) => {

View file

@ -220,7 +220,7 @@ class Reporter {
const lineNumber = location.lineNumber(); const lineNumber = location.lineNumber();
if (lineNumber < lines.length) { if (lineNumber < lines.length) {
const lineNumberLength = (lineNumber + 1 + '').length; const lineNumberLength = (lineNumber + 1 + '').length;
const FROM = Math.max(test.location().lineNumber() - 1, lineNumber - 5); const FROM = Math.max(0, lineNumber - 5);
const snippet = lines.slice(FROM, lineNumber).map((line, index) => ` ${(FROM + index + 1 + '').padStart(lineNumberLength, ' ')} | ${line}`).join('\n'); const snippet = lines.slice(FROM, lineNumber).map((line, index) => ` ${(FROM + index + 1 + '').padStart(lineNumberLength, ' ')} | ${line}`).join('\n');
const pointer = ` ` + ' '.repeat(lineNumberLength) + ' ' + '~'.repeat(location.columnNumber() - 1) + '^'; const pointer = ` ` + ' '.repeat(lineNumberLength) + ' ' + '~'.repeat(location.columnNumber() - 1) + '^';
console.log('\n' + snippet + '\n' + colors.grey(pointer) + '\n'); console.log('\n' + snippet + '\n' + colors.grey(pointer) + '\n');