feat(debug): generate preview for ElementHandle (#2549)

This is a best-effort debugging feature - we try to generate preview asynchronously,
so it might not be available immediately.
This commit is contained in:
Dmitry Gozman 2020-06-12 11:10:18 -07:00 committed by GitHub
parent defeeb9cee
commit c4e8720eb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 60 additions and 35 deletions

View File

@ -42,7 +42,7 @@ export class ConsoleMessage {
text(): string {
if (this._text === undefined)
this._text = this._args.map(arg => arg._handleToString(false /* includeType */)).join(' ');
this._text = this._args.map(arg => arg._value).join(' ');
return this._text;
}

View File

@ -121,6 +121,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
this._objectId = objectId;
this._context = context;
this._page = context.frame._page;
this._initializePreview().catch(e => {});
}
async _initializePreview() {
const utility = await this._context.injectedScript();
this._preview = await utility.evaluate((injected, e) => 'JSHandle@' + injected.previewNode(e), this);
}
asElement(): ElementHandle<T> | null {

View File

@ -487,18 +487,29 @@ export default class InjectedScript {
return element;
}
previewElement(element: Element): string {
previewNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE)
return oneLine(`#text=${node.nodeValue || ''}`);
if (node.nodeType !== Node.ELEMENT_NODE)
return oneLine(`<${node.nodeName.toLowerCase()} />`);
const element = node as Element;
const attrs = [];
for (let i = 0; i < element.attributes.length; i++) {
if (element.attributes[i].name !== 'style')
attrs.push(` ${element.attributes[i].name}="${element.attributes[i].value}"`);
const { name, value } = element.attributes[i];
if (name === 'style')
continue;
if (!value && booleanAttributes.has(name))
attrs.push(` ${name}`);
else
attrs.push(` ${name}="${value}"`);
}
attrs.sort((a, b) => a.length - b.length);
let attrText = attrs.join('');
if (attrText.length > 50)
attrText = attrText.substring(0, 49) + '\u2026';
if (autoClosingTags.has(element.nodeName))
return `<${element.nodeName.toLowerCase()}${attrText}/>`;
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}/>`);
const children = element.childNodes;
let onlyText = false;
@ -507,14 +518,19 @@ export default class InjectedScript {
for (let i = 0; i < children.length; i++)
onlyText = onlyText && children[i].nodeType === Node.TEXT_NODE;
}
let text = onlyText ? (element.textContent || '') : '';
let text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : '');
if (text.length > 50)
text = text.substring(0, 49) + '\u2026';
return `<${element.nodeName.toLowerCase()}${attrText}>${text}</${element.nodeName.toLowerCase()}>`;
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${text}</${element.nodeName.toLowerCase()}>`);
}
}
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
const booleanAttributes = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple']);
function oneLine(s: string): string {
return s.replace(/\n/g, '↵').replace(/\t/g, '⇆');
}
const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'drag'>([
['auxclick', 'mouse'],

View File

@ -20,6 +20,7 @@ import * as utilityScriptSource from './generated/utilityScriptSource';
import * as sourceMap from './utils/sourceMap';
import { serializeAsCallArgument } from './common/utilityScriptSerializers';
import { helper } from './helper';
import UtilityScript from './injected/utilityScript';
type ObjectId = string;
export type RemoteObject = {
@ -47,7 +48,7 @@ export class ExecutionContext {
return null;
}
utilityScript(): Promise<JSHandle> {
utilityScript(): Promise<JSHandle<UtilityScript>> {
if (!this._utilityScriptPromise) {
const source = `new (${utilityScriptSource.source})()`;
this._utilityScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new JSHandle(this, 'object', objectId));
@ -66,12 +67,16 @@ export class JSHandle<T = any> {
readonly _objectId: ObjectId | undefined;
readonly _value: any;
private _objectType: string;
protected _preview: string;
constructor(context: ExecutionContext, type: string, objectId?: ObjectId, value?: any) {
this._context = context;
this._objectId = objectId;
this._value = value;
this._objectType = type;
if (this._objectId)
this._value = 'JSHandle@' + this._objectType;
this._preview = 'JSHandle@' + String(this._objectId ? this._objectType : this._value);
}
async evaluate<R, Arg>(pageFunction: types.FuncOn<T, Arg, R>, arg: Arg): Promise<R>;
@ -121,14 +126,8 @@ export class JSHandle<T = any> {
await this._context._delegate.releaseHandle(this);
}
_handleToString(includeType: boolean): string {
if (this._objectId)
return 'JSHandle@' + this._objectType;
return (includeType ? 'JSHandle:' : '') + this._value;
}
toString(): string {
return this._handleToString(true);
return this._preview;
}
}

View File

@ -24,7 +24,6 @@ import { ParsedSelector, parseSelector } from './common/selectorParser';
export class Selectors {
readonly _builtinEngines: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
_generation = 0;
constructor() {
// Note: keep in sync with SelectorEvaluator class.
@ -51,7 +50,6 @@ export class Selectors {
if (this._engines.has(name))
throw new Error(`"${name}" selector engine has been already registered`);
this._engines.set(name, { source, contentScript });
++this._generation;
}
private _needsMainContext(parsed: ParsedSelector): boolean {
@ -128,7 +126,7 @@ export class Selectors {
if (!element)
progress.log(` selector did not resolve to any element`);
else
progress.log(` selector resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewElement(element)}`);
progress.log(` selector resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`);
}
switch (state) {

View File

@ -81,17 +81,16 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
if (!returnByValue)
return utilityScript._context.createHandle(response.result);
if (response.result.objectId)
return await this._returnObjectByValue(utilityScript._context, response.result.objectId);
return await this._returnObjectByValue(utilityScript, response.result.objectId);
return parseEvaluationResultValue(response.result.value);
} catch (error) {
throw rewriteError(error);
}
}
private async _returnObjectByValue(context: js.ExecutionContext, objectId: Protocol.Runtime.RemoteObjectId): Promise<any> {
private async _returnObjectByValue(utilityScript: js.JSHandle<any>, objectId: Protocol.Runtime.RemoteObjectId): Promise<any> {
// This is different from handleJSONValue in that it does not throw.
try {
const utilityScript = await context.utilityScript();
const serializeResponse = await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: 'object => object' + sourceMap.generateSourceUrl(),
objectId: utilityScript._objectId!,

View File

@ -1,2 +1,2 @@
<div id="outer" name="value"><div id="inner">Text,
more text</div></div>
more text</div></div><input id="check" type=checkbox checked foo="bar&quot;">

View File

@ -376,6 +376,18 @@ describe('ElementHandle.selectText', function() {
describe('ElementHandle convenience API', function() {
it('should have a nice preview', async({page, server}) => {
await page.goto(`${server.PREFIX}/dom.html`);
const outer = await page.$('#outer');
const inner = await page.$('#inner');
const check = await page.$('#check');
const text = await inner.evaluateHandle(e => e.firstChild);
await page.evaluate(() => 1); // Give them a chance to calculate the preview.
expect(String(outer)).toBe('JSHandle@<div id="outer" name="value">…</div>');
expect(String(inner)).toBe('JSHandle@<div id="inner">Text,↵more text</div>');
expect(String(text)).toBe('JSHandle@#text=Text,↵more text');
expect(String(check)).toBe('JSHandle@<input checked id="check" foo="bar"" type="checkbox"/>');
});
it('getAttribute should work', async({page, server}) => {
await page.goto(`${server.PREFIX}/dom.html`);
const handle = await page.$('#outer');

View File

@ -236,9 +236,9 @@ describe('JSHandle.asElement', function() {
describe('JSHandle.toString', function() {
it('should work for primitives', async({page, server}) => {
const numberHandle = await page.evaluateHandle(() => 2);
expect(numberHandle.toString()).toBe('JSHandle:2');
expect(numberHandle.toString()).toBe('JSHandle@2');
const stringHandle = await page.evaluateHandle(() => 'a');
expect(stringHandle.toString()).toBe('JSHandle:a');
expect(stringHandle.toString()).toBe('JSHandle@a');
});
it('should work for complicated objects', async({page, server}) => {
const aHandle = await page.evaluateHandle(() => window);
@ -252,15 +252,15 @@ describe('JSHandle.toString', function() {
});
it('should work with different subtypes', async({page, server}) => {
expect((await page.evaluateHandle('(function(){})')).toString()).toBe('JSHandle@function');
expect((await page.evaluateHandle('12')).toString()).toBe('JSHandle:12');
expect((await page.evaluateHandle('true')).toString()).toBe('JSHandle:true');
expect((await page.evaluateHandle('undefined')).toString()).toBe('JSHandle:undefined');
expect((await page.evaluateHandle('"foo"')).toString()).toBe('JSHandle:foo');
expect((await page.evaluateHandle('12')).toString()).toBe('JSHandle@12');
expect((await page.evaluateHandle('true')).toString()).toBe('JSHandle@true');
expect((await page.evaluateHandle('undefined')).toString()).toBe('JSHandle@undefined');
expect((await page.evaluateHandle('"foo"')).toString()).toBe('JSHandle@foo');
expect((await page.evaluateHandle('Symbol()')).toString()).toBe('JSHandle@symbol');
expect((await page.evaluateHandle('new Map()')).toString()).toBe('JSHandle@map');
expect((await page.evaluateHandle('new Set()')).toString()).toBe('JSHandle@set');
expect((await page.evaluateHandle('[]')).toString()).toBe('JSHandle@array');
expect((await page.evaluateHandle('null')).toString()).toBe('JSHandle:null');
expect((await page.evaluateHandle('null')).toString()).toBe('JSHandle@null');
expect((await page.evaluateHandle('/foo/')).toString()).toBe('JSHandle@regexp');
expect((await page.evaluateHandle('document.body')).toString()).toBe('JSHandle@node');
expect((await page.evaluateHandle('new Date()')).toString()).toBe('JSHandle@date');

View File

@ -789,10 +789,7 @@ describe('selectors.register', () => {
// Can be chained to css.
expect(await page.$eval('main=ignored >> css=section', e => e.nodeName)).toBe('SECTION');
});
it('should update', async ({page}) => {
await page.setContent('<div><dummy id=d1></dummy></div><span><dummy id=d2></dummy></span>');
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');
it('should handle errors', async ({page}) => {
let error = await page.$('neverregister=ignored').catch(e => e);
expect(error.message).toBe('Unknown engine "neverregister" while parsing selector neverregister=ignored');
@ -812,8 +809,6 @@ describe('selectors.register', () => {
expect(error.message).toBe('Selector engine name may only contain [a-zA-Z0-9_] characters');
await registerEngine('dummy', createDummySelector);
expect(await page.$eval('dummy=ignored', e => e.id)).toBe('d1');
expect(await page.$eval('css=span >> dummy=ignored', e => e.id)).toBe('d2');
error = await playwright.selectors.register('dummy', createDummySelector).catch(e => e);
expect(error.message).toBe('"dummy" selector engine has been already registered');