Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion docs/src/test-reporter-api/class-teststep.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,24 @@ The list of files or buffers attached in the step execution through [`method: Te
* since: v1.10
- type: <[string]>

User-friendly test step title.
User-friendly test step title, for example `Click` or `Navigate`.

## property: TestStep.subtitle
* since: v1.63
- type: ?<[string]>

User-friendly test step subtitle that complements the title, when available. For Playwright API
calls, it is the target locator or the navigation url. For example, a `Click` step has the clicked
locator as a subtitle. User interfaces typically render the subtitle next to the title or on a
separate line.

```js
// title `Click`, subtitle `getByRole('button')`
await page.getByRole('button').click();

// title `Navigate`, subtitle `/index.html`
await page.goto('https://example.com/index.html');
```

## method: TestStep.titlePath
* since: v1.10
Expand Down
4 changes: 4 additions & 0 deletions packages/html-reporter/src/testResultView.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
background: var(--color-attention-subtle);
}

.step-subtitle {
color: var(--color-fg-muted);
}

.step-spacer {
flex: auto;
}
Expand Down
37 changes: 23 additions & 14 deletions packages/html-reporter/src/testResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,29 @@ function pickDiffForError(error: string, diffs: ImageDiff[]): ImageDiff | undefi
}

function stepMatchesFilter(step: TestStep, filterText: string): boolean {
return step.title.toLowerCase().includes(filterText.toLowerCase());
const text = step.subtitle ? `${step.title} ${step.subtitle}` : step.title;
return text.toLowerCase().includes(filterText.toLowerCase());
}

function stepChildrenMatchFilter(step: TestStep, filterText: string): boolean {
return step.steps.some(s => stepMatchesFilter(s, filterText) || stepChildrenMatchFilter(s, filterText));
}

function highlightFilterText(text: string, filterText: string): React.ReactNode[] {
const unmatched = text.toLowerCase().split(filterText.toLowerCase());
const parts: React.ReactNode[] = [];
let index = 0;
for (let i = 0; i < unmatched.length; i++) {
if (i) {
parts.push(<span key={`highlight-${i}`} className='step-title-highlight'>{text.substring(index, index + filterText.length)}</span>);
index += filterText.length;
}
parts.push(text.substring(index, index + unmatched[i].length));
index += unmatched[i].length;
}
return parts;
}

function stepHasDescendantAttachments(step: TestStep): boolean {
return step.steps.some(s => s.attachments.length > 0 || stepHasDescendantAttachments(s));
}
Expand All @@ -252,6 +268,7 @@ const StepTreeItem: React.FC<{

let expandByDefault = false;
let title: React.ReactNode = <span>{step.title}</span>;
let subtitle: React.ReactNode = step.subtitle;

if (filterText) {
const matchesFilter = !!filterText && stepMatchesFilter(step, filterText);
Expand All @@ -260,25 +277,17 @@ const StepTreeItem: React.FC<{
return null;
expandByDefault = childrenMatchFilter;
if (matchesFilter) {
const unmatched = step.title.toLowerCase().split(filterText.toLowerCase());
const parts: React.ReactNode[] = [];
let index = 0;
for (let i = 0; i < unmatched.length; i++) {
if (i) {
parts.push(<span key={i} className='step-title-highlight'>{step.title.substring(index, index + filterText.length)}</span>);
index += filterText.length;
}
parts.push(unmatched[i]);
index += unmatched[i].length;
}
title = parts;
title = highlightFilterText(step.title, filterText);
if (step.subtitle)
subtitle = highlightFilterText(step.subtitle, filterText);
}
}

return <TreeItem title={<div aria-label={step.title} className='step-title-container'>
return <TreeItem title={<div aria-label={step.subtitle ? `${step.title} ${step.subtitle}` : step.title} className='step-title-container'>
{statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))}
<span className='step-title-text'>
{title}
{step.subtitle && <span className='step-subtitle'> {subtitle}</span>}
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
</span>
Expand Down
1 change: 1 addition & 0 deletions packages/html-reporter/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export type TestResult = {

export type TestStep = {
title: string;
subtitle?: string;
startTime: string;
duration: number;
location?: Location;
Expand Down
105 changes: 98 additions & 7 deletions packages/isomorphic/protocolFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,24 @@
*/

import { getMetainfo } from './protocolMetainfo';
import { asLocatorDescription } from './locatorGenerators';

export function formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
return _formatProtocolParam(params, alternatives)?.replaceAll('\n', '\\n');
import type { Language } from './locatorGenerators';

export type CallMetainfo = {
type: string;
method: string;
params?: Record<string, any>;
// Pre-rendered overrides, take precedence over the protocol templates.
title?: string;
subtitle?: string;
};

export function formatProtocolParam(params: Record<string, any> | undefined, alternatives: string, sdkLanguage?: Language): string | undefined {
return _formatProtocolParam(params, alternatives, sdkLanguage)?.replaceAll('\n', '\\n');
}

function _formatProtocolParam(params: Record<string, string> | undefined, alternatives: string): string | undefined {
function _formatProtocolParam(params: Record<string, any> | undefined, alternatives: string, sdkLanguage?: Language): string | undefined {
if (!params)
return undefined;

Expand All @@ -44,8 +56,11 @@ function _formatProtocolParam(params: Record<string, string> | undefined, altern
}

const value = deepParam(params, name);
if (value !== undefined)
return value;
if (value === undefined)
continue;
if (name === 'selector' || name.endsWith('.selector'))
return asLocatorDescription(sdkLanguage ?? 'javascript', value);
return value;
}
}

Expand All @@ -62,15 +77,91 @@ function deepParam(params: Record<string, any>, name: string): string | undefine
return String(current);
}

export function renderTitleForCall(metadata: { title?: string, type: string, method: string, params: Record<string, string> | undefined }) {
export function renderTitleForCall(metadata: CallMetainfo, sdkLanguage?: Language): string {
const titleFormat = metadata.title ?? getMetainfo(metadata)?.title ?? metadata.method;
return titleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => {
return formatProtocolParam(metadata.params, p1) ?? fullMatch;
return formatProtocolParam(metadata.params, p1, sdkLanguage) ?? fullMatch;
});
}

export function renderSubtitleForCall(metadata: CallMetainfo, sdkLanguage?: Language): string | undefined {
const subtitleFormat = metadata.subtitle ?? getMetainfo(metadata)?.subtitle;
if (subtitleFormat === undefined)
return undefined;
let allParamsResolved = true;
const subtitle = subtitleFormat.replace(/\{([^}]+)\}/g, (fullMatch, p1) => {
const param = formatProtocolParam(metadata.params, p1, sdkLanguage);
if (param === undefined)
allParamsResolved = false;
return param ?? fullMatch;
});
return allParamsResolved ? subtitle : undefined;
}

export function renderFullTitleForCall(metadata: CallMetainfo, sdkLanguage?: Language): string {
const title = renderTitleForCall(metadata, sdkLanguage);
const subtitle = renderSubtitleForCall(metadata, sdkLanguage);
return subtitle ? `${title} ${subtitle}` : title;
}

export type ActionGroup = 'configuration' | 'route' | 'getter';

export function getActionGroup(metadata: { type: string, method: string }) {
return getMetainfo(metadata)?.group as undefined | ActionGroup;
}

const kMaxParamLength = 200;

// Curated per-call parameters, defined by the "renderParams" lists in protocol.yml. Only the
// arguments that say what the call actually did are reported, and only when they are
// bounded in size: page content, evaluated expressions, request bodies and the like are
// never reported, and neither are options that repeat their default on every call.
export function renderParamsForCall(metadata: CallMetainfo, sdkLanguage?: Language): Record<string, any> | undefined {
if (!metadata.params)
return undefined;
const result: Record<string, any> = {};
const locator = renderLocator(metadata.params.selector, sdkLanguage);
if (locator !== undefined)
result.locator = locator;
for (const entry of getMetainfo(metadata)?.renderParams ?? []) {
const { key, value } = renderParamEntry(metadata.params, entry, sdkLanguage);
if (value !== undefined)
result[key] = value;
}
return Object.keys(result).length ? result : undefined;
}

// Each entry is "[key=]path[:selector]": a dotted path into the call params, reported
// under the last path segment unless an explicit key is given, with selector values
// rendered as locators.
function renderParamEntry(params: Record<string, any>, entry: string, sdkLanguage?: Language): { key: string, value: any } {
const [spec, format] = entry.split(':');
const eqIndex = spec.indexOf('=');
const path = eqIndex === -1 ? spec : spec.slice(eqIndex + 1);
const key = eqIndex === -1 ? path.split('.').pop()! : spec.slice(0, eqIndex);
let value: any = params;
for (const token of path.split('.')) {
if (typeof value !== 'object' || value === null) {
value = undefined;
break;
}
value = value[token];
}
if (value === undefined)
return { key, value };
if (format === 'selector')
return { key, value: renderLocator(value, sdkLanguage) };
if (typeof value === 'string')
return { key, value: truncateParam(value) };
return { key, value };
}

function renderLocator(selector: any, sdkLanguage?: Language): string | undefined {
if (typeof selector !== 'string')
return undefined;
return truncateParam(asLocatorDescription(sdkLanguage ?? 'javascript', selector));
}

export function truncateParam(value: string): string {
return value.length > kMaxParamLength ? value.substring(0, kMaxParamLength) + '…' : value;
}
Loading
Loading