Skip to content

Commit

Permalink
feat(reporter): report TestStep#attachments (#34037)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skn0tt authored Jan 2, 2025
1 parent 175f05c commit 04a3574
Show file tree
Hide file tree
Showing 18 changed files with 265 additions and 54 deletions.
10 changes: 10 additions & 0 deletions docs/src/test-reporter-api/class-teststep.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ Start time of this particular test step.

List of steps inside this step.

## property: TestStep.attachments
* since: v1.50
- type: <[Array]<[Object]>>
- `name` <[string]> Attachment name.
- `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
- `path` ?<[string]> Optional path on the filesystem to the attached file.
- `body` ?<[Buffer]> Optional attachment body used instead of a file.

The list of files or buffers attached in the step execution through [`method: TestInfo.attach`].

## property: TestStep.title
* since: v1.10
- type: <[string]>
Expand Down
5 changes: 3 additions & 2 deletions packages/html-reporter/src/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ export const ProjectLink: React.FunctionComponent<{

export const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment,
result: TestResult,
href?: string,
linkName?: string,
openInNewTab?: boolean,
}> = ({ attachment, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + attachment.name);
}> = ({ attachment, result, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment));
return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
Expand Down
3 changes: 3 additions & 0 deletions packages/html-reporter/src/testCaseView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ const result: TestResult = {
duration: 10,
location: { file: 'test.spec.ts', line: 82, column: 0 },
steps: [],
attachments: [],
count: 1,
}],
attachments: [],
}],
attachments: [],
status: 'passed',
Expand Down Expand Up @@ -139,6 +141,7 @@ const resultWithAttachment: TestResult = {
location: { file: 'test.spec.ts', line: 62, column: 0 },
count: 1,
steps: [],
attachments: [1],
}],
attachments: [{
name: 'first attachment',
Expand Down
2 changes: 1 addition & 1 deletion packages/html-reporter/src/testFileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
for (const result of test.results) {
for (const attachment of result.attachments) {
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
return <Link href={testResultHref({ test, result, anchor: `attachment-${result.attachments.indexOf(attachment)}` })} title='View images' className='test-file-badge'>{image()}</Link>;
}
}
}
Expand Down
27 changes: 13 additions & 14 deletions packages/html-reporter/src/testResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ interface ImageDiffWithAnchors extends ImageDiff {
anchors: string[];
}

function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
function groupImageDiffs(screenshots: Set<TestAttachment>, result: TestResult): ImageDiffWithAnchors[] {
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
for (const attachment of screenshots) {
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
Expand All @@ -45,7 +45,7 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
snapshotNameToImageDiff.set(snapshotName, imageDiff);
}
imageDiff.anchors.push(`attachment-${attachment.name}`);
imageDiff.anchors.push(`attachment-${result.attachments.indexOf(attachment)}`);
if (category === 'actual')
imageDiff.actual = { attachment };
if (category === 'expected')
Expand All @@ -72,15 +72,15 @@ export const TestResultView: React.FC<{
result: TestResult,
}> = ({ test, result }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
const attachments = result?.attachments || [];
const attachments = result.attachments;
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`);
const screenshotAnchors = [...screenshots].map(a => `attachment-${attachments.indexOf(a)}`);
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
const traces = attachments.filter(a => a.name === 'trace');
const otherAttachments = new Set<TestAttachment>(attachments);
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
const diffs = groupImageDiffs(screenshots);
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${attachments.indexOf(a)}`);
const diffs = groupImageDiffs(screenshots, result);
const errors = classifyErrors(result.errors, diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
}, [result]);
Expand All @@ -107,11 +107,11 @@ export const TestResultView: React.FC<{

{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
{screenshots.map((a, i) => {
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
return <Anchor key={`screenshot-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
<a href={a.path}>
<img className='screenshot' src={a.path} />
</a>
<AttachmentLink attachment={a}></AttachmentLink>
<AttachmentLink attachment={a} result={result}></AttachmentLink>
</Anchor>;
})}
</AutoChip>}
Expand All @@ -121,7 +121,7 @@ export const TestResultView: React.FC<{
<a href={generateTraceUrl(traces)}>
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
</a>
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} result={result} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
</div>}
</AutoChip></Anchor>}

Expand All @@ -130,14 +130,14 @@ export const TestResultView: React.FC<{
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
<AttachmentLink attachment={a} result={result}></AttachmentLink>
</div>)}
</AutoChip></Anchor>}

{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
{[...otherAttachments].map((a, i) =>
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
<Anchor key={`attachment-link-${i}`} id={`attachment-${result.attachments.indexOf(a)}`}>
<AttachmentLink attachment={a} result={result} openInNewTab={a.contentType.startsWith('text/html')} />
</Anchor>
)}
</AutoChip>}
Expand Down Expand Up @@ -174,10 +174,9 @@ const StepTreeItem: React.FC<{
step: TestStep;
depth: number,
}> = ({ test, step, result, depth }) => {
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{step.attachments.length > 0 && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${step.attachments[0]}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
<span>{step.title}</span>
{step.count > 1 && <><span className='test-result-counter'>{step.count}</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 @@ -108,5 +108,6 @@ export type TestStep = {
snippet?: string;
error?: string;
steps: TestStep[];
attachments: number[];
count: number;
};
1 change: 1 addition & 0 deletions packages/playwright/src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type AttachmentPayload = {
path?: string;
body?: string;
contentType: string;
stepId?: string;
};

export type TestInfoErrorImpl = TestInfoError & {
Expand Down
17 changes: 14 additions & 3 deletions packages/playwright/src/isomorphic/teleReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export type JsonTestStepEnd = {
id: string;
duration: number;
error?: reporterTypes.TestError;
attachments?: number[]; // index of JsonTestResultEnd.attachments
};

export type JsonFullResult = {
Expand Down Expand Up @@ -249,7 +250,7 @@ export class TeleReporterReceiver {
const parentStep = payload.parentStepId ? result._stepMap.get(payload.parentStepId) : undefined;

const location = this._absoluteLocation(payload.location);
const step = new TeleTestStep(payload, parentStep, location);
const step = new TeleTestStep(payload, parentStep, location, result);
if (parentStep)
parentStep.steps.push(step);
else
Expand All @@ -262,6 +263,7 @@ export class TeleReporterReceiver {
const test = this._tests.get(testId)!;
const result = test.results.find(r => r._id === resultId)!;
const step = result._stepMap.get(payload.id)!;
step._endPayload = payload;
step.duration = payload.duration;
step.error = payload.error;
this._reporter.onStepEnd?.(test, result, step);
Expand Down Expand Up @@ -512,15 +514,20 @@ class TeleTestStep implements reporterTypes.TestStep {
parent: reporterTypes.TestStep | undefined;
duration: number = -1;
steps: reporterTypes.TestStep[] = [];
error: reporterTypes.TestError | undefined;

private _result: TeleTestResult;
_endPayload?: JsonTestStepEnd;

private _startTime: number = 0;

constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined) {
constructor(payload: JsonTestStepStart, parentStep: reporterTypes.TestStep | undefined, location: reporterTypes.Location | undefined, result: TeleTestResult) {
this.title = payload.title;
this.category = payload.category;
this.location = location;
this.parent = parentStep;
this._startTime = payload.startTime;
this._result = result;
}

titlePath() {
Expand All @@ -535,6 +542,10 @@ class TeleTestStep implements reporterTypes.TestStep {
set startTime(value: Date) {
this._startTime = +value;
}

get attachments() {
return this._endPayload?.attachments?.map(index => this._result.attachments[index]) ?? [];
}
}

export class TeleTestResult implements reporterTypes.TestResult {
Expand All @@ -550,7 +561,7 @@ export class TeleTestResult implements reporterTypes.TestResult {
errors: reporterTypes.TestResult['errors'] = [];
error: reporterTypes.TestResult['error'];

_stepMap: Map<string, reporterTypes.TestStep> = new Map();
_stepMap = new Map<string, TeleTestStep>();
_id: string;

private _startTime: number = 0;
Expand Down
18 changes: 12 additions & 6 deletions packages/playwright/src/reporters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ class HtmlBuilder {
duration: result.duration,
startTime: result.startTime.toISOString(),
retry: result.retry,
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)),
steps: dedupeSteps(result.steps).map(s => this._createTestStep(s, result)),
errors: formatResultFailure(test, result, '', true).map(error => error.message),
status: result.status,
attachments: this._serializeAttachments([
Expand All @@ -515,20 +515,26 @@ class HtmlBuilder {
};
}

private _createTestStep(dedupedStep: DedupedStep): TestStep {
private _createTestStep(dedupedStep: DedupedStep, result: api.TestResult): TestStep {
const { step, duration, count } = dedupedStep;
const result: TestStep = {
const testStep: TestStep = {
title: step.title,
startTime: step.startTime.toISOString(),
duration,
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)),
steps: dedupeSteps(step.steps).map(s => this._createTestStep(s, result)),
attachments: step.attachments.map(s => {
const index = result.attachments.indexOf(s);
if (index === -1)
throw new Error('Unexpected, attachment not found');
return index;
}),
location: this._relativeLocation(step.location),
error: step.error?.message,
count
};
if (step.location)
this._stepsInFile.set(step.location.file, result);
return result;
this._stepsInFile.set(step.location.file, testStep);
return testStep;
}

private _relativeLocation(location: api.Location | undefined): api.Location | undefined {
Expand Down
5 changes: 3 additions & 2 deletions packages/playwright/src/reporters/teleEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class TeleReporterEmitter implements ReporterV2 {
params: {
testId: test.id,
resultId: (result as any)[this._idSymbol],
step: this._serializeStepEnd(step)
step: this._serializeStepEnd(step, result)
}
});
}
Expand Down Expand Up @@ -251,11 +251,12 @@ export class TeleReporterEmitter implements ReporterV2 {
};
}

private _serializeStepEnd(step: reporterTypes.TestStep): teleReceiver.JsonTestStepEnd {
private _serializeStepEnd(step: reporterTypes.TestStep, result: reporterTypes.TestResult): teleReceiver.JsonTestStepEnd {
return {
id: (step as any)[this._idSymbol],
duration: step.duration,
error: step.error,
attachments: step.attachments.map(a => result.attachments.indexOf(a)),
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/playwright/src/runner/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ class JobDispatcher {
startTime: new Date(params.wallTime),
duration: -1,
steps: [],
attachments: [],
location: params.location,
};
steps.set(params.stepId, step);
Expand Down Expand Up @@ -361,6 +362,13 @@ class JobDispatcher {
body: params.body !== undefined ? Buffer.from(params.body, 'base64') : undefined
};
data.result.attachments.push(attachment);
if (params.stepId) {
const step = data.steps.get(params.stepId);
if (step)
step.attachments.push(attachment);
else
this._reporter.onStdErr?.('Internal error: step id not found: ' + params.stepId);
}
}

private _failTestWithErrors(test: TestCase, errors: TestError[]) {
Expand Down
Loading

0 comments on commit 04a3574

Please sign in to comment.