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
5 changes: 5 additions & 0 deletions docs/src/test-reporter-api/class-reporter.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ Reporter is allowed to override the status and hence affect the exit code of the
- `status` <[FullStatus]<"passed"|"failed"|"timedout"|"interrupted">> Test run status.
- `startTime` <[Date]> Test run start wall time.
- `duration` <[int]> Test run duration in milliseconds.
- `shards` <[Array]<[Object]>> Only present on merged reports
- `shardIndex` ?<[int]> The index of the shard, one-based.
- `tag` ?<[Array]<[string]>> Global [`property: TestConfig.tag`] that differentiates CI environments
- `startTime` <[Date]> Start wall time of shard.
- `duration` <[int]> Shard run duration in milliseconds.

Result of the full test run, `status` can be one of:
* `'passed'` - Everything went as expected.
Expand Down
4 changes: 2 additions & 2 deletions docs/src/test-reporter-api/class-testresult.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,6 @@ The index of the worker between `0` and `workers - 1`. It is guaranteed that wor

## property: TestResult.shardIndex
* since: v1.58
- type: <[int]>
- type: ?<[int]>

The index of the shard between `0` and `shards - 1`.
The index of the shard between `1` and [`shards`](../test-sharding.md).
28 changes: 28 additions & 0 deletions packages/html-reporter/src/barchart.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
Copyright (c) Microsoft Corporation.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

.barchart-bar {
transition: opacity 0.2s;
cursor: pointer;
outline: none;
}

.barchart-bar:hover,
.barchart-bar:focus {
opacity: 0.8;
stroke: var(--color-fg-default);
stroke-width: 2;
}
234 changes: 234 additions & 0 deletions packages/html-reporter/src/barchart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import './barchart.css';

const formatDuration = (ms: number): string => {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes === 0)
return `${seconds}s`;
return `${minutes}m ${seconds}s`;
};

export const GroupedBarChart = ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reminds me of timeline.tsx quite a lot!

Copy link
Member Author

@Skn0tt Skn0tt Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe that was part of the training data 😁

data,
groups,
series,
}: {
data: number[][];
groups: string[];
series: string[];
}) => {
const width = 800;

// Calculate left margin based on longest group name
// Rough estimate: 7 pixels per character at fontSize 12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether you should make it two columns, let the titles column take as much space as it needs (say, up to 50%) and the bars column take the rest?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly sure what you mean, but I allowed the titles to take up to 50% now.

const maxGroupNameLength = Math.max(...groups.map(g => g.length));
const estimatedTextWidth = maxGroupNameLength * 7;
const leftMargin = Math.min(width * 0.5, Math.max(50, estimatedTextWidth));

const margin = { top: 20, right: 20, bottom: 40, left: leftMargin };
const chartWidth = width - margin.left - margin.right;

const maxValue = Math.max(...data.flat());

let tickInterval: number;
let formatTickLabel: (i: number) => string;

if (maxValue < 60 * 1000) {
tickInterval = 10 * 1000;
formatTickLabel = i => `${i * 10}s`;
} else if (maxValue < 5 * 60 * 1000) {
tickInterval = 30 * 1000;
formatTickLabel = i => {
const seconds = i * 30;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs === 0 ? `${mins}m` : `${mins}m${secs}s`;
};
} else if (maxValue < 30 * 60 * 1000) {
tickInterval = 5 * 60 * 1000;
formatTickLabel = i => `${i * 5}m`;
} else {
tickInterval = 10 * 60 * 1000;
formatTickLabel = i => `${i * 10}m`;
}

const maxRounded = Math.ceil(maxValue / tickInterval) * tickInterval;
const xScale = chartWidth / maxRounded;

// Calculate the number of actual bars per group (non-zero values)
const barsPerGroup = data.map(group => group.length);

// Allocate space proportionally based on number of bars
const barHeight = 20; // Fixed bar height
const barSpacing = 4;
const groupPadding = 12;

// Calculate Y positions for each group based on their bar count
const groupYPositions: number[] = [];
let currentY = 0;
for (let i = 0; i < groups.length; i++) {
groupYPositions.push(currentY);
const groupHeight = barsPerGroup[i] * barHeight + (barsPerGroup[i] - 1) * barSpacing + groupPadding;
currentY += groupHeight;
}

const contentHeight = currentY;

const xTicks = [];
const numberOfTicks = Math.ceil(maxRounded / tickInterval);
for (let i = 0; i <= numberOfTicks; i++) {
xTicks.push({
x: i * tickInterval * xScale,
label: formatTickLabel(i)
});
}

const height = contentHeight + margin.top + margin.bottom;

return (
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio='xMidYMid meet'
style={{ width: '100%', height: 'auto' }}
role='img'
>
<g transform={`translate(${margin.left}, ${margin.top})`} role='presentation'>
{xTicks.map(({ x, label }, i) => (
<g key={i} aria-hidden='true'>
<line
x1={x}
y1={0}
x2={x}
y2={contentHeight}
stroke='var(--color-border-muted)'
strokeWidth='1'
/>
<text
x={x}
y={contentHeight + 20}
textAnchor='middle'
dominantBaseline='middle'
fontSize='12'
fill='var(--color-fg-muted)'
>
{label}
</text>
</g>
))}

{groups.map((group, groupIndex) => {
const groupY = groupYPositions[groupIndex];
let barIndex = 0;

return (
<g key={groupIndex} role='list' aria-label={group}>
{series.map((seriesName, seriesIndex) => {
const value = data[groupIndex][seriesIndex];
if (value === undefined || Number.isNaN(value))
return null;

const barWidth = value * xScale;
const x = 0;
const y = groupY + barIndex * (barHeight + barSpacing);
barIndex++;

const colors = ['var(--color-scale-yellow-3)', 'var(--color-scale-orange-4)', 'var(--color-scale-blue-3)', 'var(--color-scale-green-3)'];
const color = colors[seriesIndex % colors.length];

return (
<g
key={`${groupIndex}-${seriesIndex}`}
role='listitem'
aria-label={`${seriesName}: ${formatDuration(value)}`}
>
<rect
className='barchart-bar'
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={color}
rx='2'
tabIndex={0}
>
<title>{`${seriesName}: ${formatDuration(value)}`}</title>
</rect>
<text
x={barWidth + 6}
y={y + barHeight / 2}
dominantBaseline='middle'
fontSize='12'
fill='var(--color-fg-muted)'
aria-hidden='true'
>
{formatDuration(value)}
</text>
</g>
);
})}
</g>
);
})}

{groups.map((group, groupIndex) => {
const groupY = groupYPositions[groupIndex];
const actualBars = barsPerGroup[groupIndex];
const groupHeight = actualBars * barHeight + (actualBars - 1) * barSpacing;
const labelY = groupY + groupHeight / 2;

return (
<text
key={groupIndex}
x={-10}
y={labelY}
textAnchor='end'
dominantBaseline='middle'
fontSize='12'
fill='var(--color-fg-muted)'
aria-hidden='true'
>
{group}
</text>
);
})}

<line
x1={0}
y1={0}
x2={0}
y2={contentHeight}
stroke='var(--color-fg-muted)'
strokeWidth='1'
aria-hidden='true'
/>

<line
x1={0}
y1={contentHeight}
x2={chartWidth}
y2={contentHeight}
stroke='var(--color-fg-muted)'
strokeWidth='1'
aria-hidden='true'
/>
</g>
</svg>
);
};
37 changes: 37 additions & 0 deletions packages/html-reporter/src/speedboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ import { LoadedReport } from './loadedReport';
import { TestFileView } from './testFileView';
import * as icons from './icons';
import { TestCaseSummary } from './types';
import { AutoChip } from './chip';
import { GroupedBarChart } from './barchart';

export function Speedboard({ report, tests }: { report: LoadedReport, tests: TestCaseSummary[] }) {
return <>
<Shards report={report} />
<SlowestTests report={report} tests={tests} />
</>;
}
Expand All @@ -46,3 +49,37 @@ export function SlowestTests({ report, tests }: { report: LoadedReport, tests: T
}
/>;
}

export function Shards({ report }: { report: LoadedReport }) {
const shards = report.json().shards;
if (shards.length === 0)
return null;

let clash = false;
const bots: Record<string, number[]> = {};
for (const shard of shards) {
const botName = shard.tag.join(' ');
bots[botName] ??= [];
const shardIndex = Math.max((shard.shardIndex ?? 1) - 1, 0);
if (bots[botName][shardIndex] !== undefined)
clash = true;
bots[botName][shardIndex] = shard.duration;
}

const maxSeries = Math.max(...Object.values(bots).map(shardDurations => shardDurations.length));

return <AutoChip header='Shard Duration'>
<GroupedBarChart
data={Object.values(bots)}
groups={Object.keys(bots)}
series={Array.from({ length: maxSeries }).map((_, i) => `Shard ${i + 1}`)}
/>
{clash && <div style={{ marginTop: 8 }}>
<icons.warning />
Some shards could not be differentiated because of missing global tags.
Please refer to <a href='https://playwright.dev/docs/test-sharding#merging-reports-from-multiple-environments' target='_blank' rel='noopener noreferrer'>
the docs
</a> on how to fix this.
</div>}
</AutoChip>;
}
6 changes: 6 additions & 0 deletions packages/html-reporter/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export type HTMLReport = {
projectNames: string[];
startTime: number;
duration: number;
shards: {
shardIndex?: number;
tag: string[];
startTime: number;
duration: number;
}[];
errors: string[]; // Top-level errors that are not attributed to any test.
options: HTMLReportOptions;
};
Expand Down
16 changes: 14 additions & 2 deletions packages/playwright/src/isomorphic/teleReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export type JsonTestResultStart = {
retry: number;
workerIndex: number;
parallelIndex: number;
shardIndex: number;
shardIndex?: number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also update TeleTestResult.shardIndex definition to be undefined by default?

startTime: number;
};

Expand Down Expand Up @@ -130,6 +130,12 @@ export type JsonFullResult = {
status: reporterTypes.FullResult['status'];
startTime: number;
duration: number;
shards?: {
shardIndex?: number;
tag: string[];
startTime: number;
duration: number;
}[];
};

export type JsonEvent = JsonOnConfigureEvent | JsonOnBlobReportMetadataEvent | JsonOnEndEvent | JsonOnExitEvent | JsonOnProjectEvent | JsonOnBeginEvent | JsonOnTestBeginEvent
Expand Down Expand Up @@ -452,6 +458,12 @@ export class TeleReporterReceiver {
status: result.status,
startTime: new Date(result.startTime),
duration: result.duration,
shards: result.shards?.map(s => ({
shardIndex: s.shardIndex,
tag: s.tag,
startTime: new Date(s.startTime),
duration: s.duration,
})) ?? [],
});
}

Expand Down Expand Up @@ -725,7 +737,7 @@ export class TeleTestResult implements reporterTypes.TestResult {
retry: reporterTypes.TestResult['retry'];
parallelIndex: reporterTypes.TestResult['parallelIndex'] = -1;
workerIndex: reporterTypes.TestResult['workerIndex'] = -1;
shardIndex: reporterTypes.TestResult['shardIndex'] = -1;
shardIndex: reporterTypes.TestResult['shardIndex'];
duration: reporterTypes.TestResult['duration'] = -1;
stdout: reporterTypes.TestResult['stdout'] = [];
stderr: reporterTypes.TestResult['stderr'] = [];
Expand Down
Loading
Loading