fix: JSHandle preview text for non-ascii attributes/children (#28038)

This surfaced in .NET that the string in the driver got incorrectly cut,
then transferred to .NET as an invalid UTF8 character
[`\ud835`](https://charbase.com/d835-unicode-invalid-character) which
.NET wasn't able to parse and threw an error.

Drive-by: Move similar function from
`packages/playwright-core/src/client/page.ts` into isomorphic
`stringUtils`.

https://github.com/microsoft/playwright-dotnet/issues/2748
This commit is contained in:
Max Schmitt 2023-11-09 00:11:01 +01:00 committed by GitHub
parent 5a9fa69c6d
commit 5f527fedb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 30 additions and 19 deletions

View File

@ -42,6 +42,7 @@ import { Keyboard, Mouse, Touchscreen } from './input';
import { assertMaxArguments, JSHandle, parseResult, serializeArgument } from './jsHandle';
import type { FrameLocator, Locator, LocatorOptions } from './locator';
import type { ByRoleOptions } from '../utils/isomorphic/locatorUtils';
import { trimStringWithEllipsis } from '../utils/isomorphic/stringUtils';
import { type RouteHandlerCallback, type Request, Response, Route, RouteHandler, validateHeaders, WebSocket } from './network';
import type { FilePayload, Headers, LifecycleEvent, SelectOption, SelectOptionOptions, Size, URLMatch, WaitForEventOptions, WaitForFunctionOptions } from './types';
import { Video } from './video';
@ -751,15 +752,9 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel> {
}
}
function trimEnd(s: string): string {
if (s.length > 50)
s = s.substring(0, 50) + '\u2026';
return s;
}
function trimUrl(param: any): string | undefined {
if (isRegExp(param))
return `/${trimEnd(param.source)}/${param.flags}`;
return `/${trimStringWithEllipsis(param.source, 50)}/${param.flags}`;
if (isString(param))
return `"${trimEnd(param)}"`;
return `"${trimStringWithEllipsis(param, 50)}"`;
}

View File

@ -33,7 +33,7 @@ import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName } fr
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
@ -1072,9 +1072,7 @@ export class InjectedScript {
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';
const attrText = trimStringWithEllipsis(attrs.join(''), 50);
if (autoClosingTags.has(element.nodeName))
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}/>`);
@ -1085,10 +1083,8 @@ export class InjectedScript {
for (let i = 0; i < children.length; i++)
onlyText = onlyText && children[i].nodeType === Node.TEXT_NODE;
}
let text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : '');
if (text.length > 50)
text = text.substring(0, 49) + '\u2026';
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${text}</${element.nodeName.toLowerCase()}>`);
const text = onlyText ? (element.textContent || '') : (children.length ? '\u2026' : '');
return oneLine(`<${element.nodeName.toLowerCase()}${attrText}>${trimStringWithEllipsis(text, 50)}</${element.nodeName.toLowerCase()}>`);
}
strictModeViolationError(selector: ParsedSelector, matches: Element[]): Error {

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils';
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace, quoteCSSAttributeValue, trimString } from '../../utils/isomorphic/stringUtils';
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
import type { InjectedScript } from './injectedScript';
import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
@ -276,7 +276,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
}
const fullText = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full);
const text = fullText.substring(0, 80);
const text = trimString(fullText, 80);
if (text) {
const escaped = escapeForTextSelector(text, false);
if (isTargetNode) {

View File

@ -103,3 +103,16 @@ export function escapeForAttributeSelector(value: string | RegExp, exact: boolea
// so we escape them differently.
return `"${value.replace(/\\/g, '\\\\').replace(/["]/g, '\\"')}"${exact ? 's' : 'i'}`;
}
export function trimString(input: string, cap: number, suffix: string = ''): string {
if (input.length <= cap)
return input;
const chars = [...input];
if (chars.length > cap)
return chars.slice(0, cap - suffix.length).join('') + suffix;
return chars.join('');
}
export function trimStringWithEllipsis(input: string, cap: number): string {
return trimString(input, cap, '\u2026');
}

View File

@ -30,6 +30,13 @@ it('should have a nice preview', async ({ page, server }) => {
expect(String(check)).toBe('JSHandle@<input checked id="check" foo="bar"" type="checkbox"/>');
});
it('should have a nice preview for non-ascii attributes/children', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`<div title="${'😛'.repeat(100)}">${'😛'.repeat(100)}`);
const handle = await page.$('div');
await expect.poll(() => String(handle)).toBe(`JSHandle@<div title=\"😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛…>😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛😛…</div>`);
});
it('getAttribute should work', async ({ page, server }) => {
await page.goto(`${server.PREFIX}/dom.html`);
const handle = await page.$('#outer');

View File

@ -63,7 +63,7 @@ it('should respect default timeout', async ({ page, playwright }) => {
it('should log the url', async ({ page }) => {
const error = await page.waitForRequest('long-long-long-long-long-long-long-long-long-long-long-long-long-long.css', { timeout: 1000 }).catch(e => e);
expect(error.message).toContain('waiting for request "long-long-long-long-long-long-long-long-long-long-…"');
expect(error.message).toContain('waiting for request "long-long-long-long-long-long-long-long-long-long…"');
});
it('should work with no timeout', async ({ page, server }) => {