diff --git a/CHANGELOG.md b/CHANGELOG.md index 80bd6c78..b6c59818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -583,3 +583,7 @@ to rebuild them every time the envelope list is updated. Use this instead of `` component showing scenario execution over time grouped by worker ([#126](https://github.com/cucumber/react-components/issues/126)) diff --git a/src/components/app/Report.module.scss b/src/components/app/Report.module.scss index b8d3c8f4..dfbff60e 100644 --- a/src/components/app/Report.module.scss +++ b/src/components/app/Report.module.scss @@ -1,3 +1,5 @@ +@use '../../styles/theming'; + .layout { > section:not(:last-child) { margin-bottom: 1.5em; @@ -8,3 +10,37 @@ font-size: 1.25em; margin: 1em 0; } + +.tabList { + display: flex; + gap: 0.25em; + border-bottom: 1px solid theming.$panelAccentColor; + margin-bottom: 1em; +} + +.tab { + padding: 0.5em 1em; + border-bottom: 2px solid transparent; + color: theming.$panelTextColor; + cursor: pointer; + outline: none; + + &[data-hovered] { + background-color: theming.$panelBackgroundColor; + } + + &[data-selected] { + border-bottom-color: theming.$anchorColor; + color: theming.$anchorColor; + font-weight: 600; + } + + &[data-focus-visible] { + outline: 2px solid theming.$anchorColor; + outline-offset: 2px; + } +} + +.tabPanel { + outline: none; +} \ No newline at end of file diff --git a/src/components/app/Report.tsx b/src/components/app/Report.tsx index f6e2a61c..5fa206b9 100644 --- a/src/components/app/Report.tsx +++ b/src/components/app/Report.tsx @@ -1,10 +1,12 @@ import type { FC } from 'react' +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'react-aria-components' import { ExecutionSummary } from './ExecutionSummary.js' import { FilteredDocuments } from './FilteredDocuments.js' import styles from './Report.module.scss' import { SearchBar } from './SearchBar.js' import { TestRunHooks } from './TestRunHooks.js' +import { Timeline } from './Timeline.js' export const Report: FC = () => { return ( @@ -14,8 +16,24 @@ export const Report: FC = () => {
-

Scenarios

- + + + + Scenarios + + + Timeline + + + + + + + + + + +

Hooks

diff --git a/src/components/app/Timeline.module.scss b/src/components/app/Timeline.module.scss new file mode 100644 index 00000000..88f2a9be --- /dev/null +++ b/src/components/app/Timeline.module.scss @@ -0,0 +1,223 @@ +@use '../../styles/statuses'; +@use '../../styles/theming'; +@use 'sass:color'; + +.timelineWrapper { + display: flex; + flex-direction: column; + padding: 1px; +} + +.timelineRow { + width: 100%; + display: grid; + grid-template-columns: 1fr minmax(0, 4fr); + gap: 1px; + border: 1px solid theming.$panelAccentColor; + border-top: none; +} + +.timelineRow:first-child { + border-top: 1px solid theming.$panelAccentColor; +} + +.cell { + height: 3em; +} + +.cell:first-child { + border-right: 1px solid theming.$panelAccentColor; +} + +.leftCell { + display: flex; + align-items: center; + padding-left: 0.5em; + font-family: inherit; +} + +// Timeline Bar Styles + +.timelineBarWrapper { + display: flex; + overflow: hidden; + align-items: center; + position: sticky; // Allows setting timelineBar position: absolute +} + +.timelineBar { + height: 80%; + border-radius: 0.333em; + border: none; + padding: 0; + box-sizing: border-box; + flex: none; + position: absolute; +} + +@each $name, $color in statuses.$statusColors { + .timelineBar[data-status='#{$name}'] { + background-color: color.adjust($color, $alpha: -0.666); + border-color: $color; + + &:hover { + outline: 1px solid $color; + } + + &.selected { + outline: 1px solid $color + } +} +} + +// Tooltip Styles + +.icon { + padding-top: 0.35em; + padding-left: 0.5em; + padding-right: 0.5em; +} + +// Tooltip card +:global(.react-aria-Tooltip) { + display: flex; + flex-direction: column; + overflow: hidden; + background-color: theming.$panelBackgroundColor; + color: theming.$panelTextColor; + border: 1px solid theming.$panelAccentColor; + border-radius: 0.25em; + padding: 0.4em 0.6em; + font-size: inherit; + font-family: inherit; + align-items: center; + gap: 0.4em; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + max-width: 20em; + margin-bottom: 0.7em; +} + +.tooltipBtn { + border: none; + background: none; +} + +// Arrow styling +:global(.react-aria-Tooltip) svg { + fill: theming.$exampleNumberColor; + stroke: theming.$panelAccentColor; + stroke-width: 1px; +} + +// Axis Styles + +.axisUnit { + font-size: 0.65em; + text-transform: uppercase; + letter-spacing: 0.05em; + color: theming.$exampleNumberColor; + opacity: 0.9; +} + +.axisRuler { + position: relative; + overflow: hidden; + cursor: grab; + user-select: none; + border: none; + background-color: inherit; + padding: 0; + margin: 0; +} + +.axisRuler:active { + cursor: grabbing; +} + +.minorTick { + position: absolute; + bottom: 0; + width: 2px; + height: 30%; + background-color: theming.$panelAccentColor; + pointer-events: none; +} + +.majorTick { + position: absolute; + bottom: 0; + width: 2px; + height: 55%; + background-color: theming.$exampleNumberColor; + pointer-events: none; +} + +.tickLabel { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + font-size: 0.6em; + line-height: 1.5; + white-space: nowrap; + color: theming.$panelTextColor; + opacity: 0.75; + pointer-events: none; +} + + +// Timeline Bar Detail Styles + +.detail { + position: relative; + padding: 1em; + background-color: theming.$panelBackgroundColor; + color: theming.$panelTextColor; + border: 1px solid theming.$panelAccentColor; + border-radius: 0.25em; +} + +.detailClose { + position: absolute; + top: 0.5em; + right: 0.5em; + padding: 0.25em; + background: none; + border: none; + cursor: pointer; + color: inherit; +} + +.detailTitle { + display: flex; + align-items: center; + gap: 0.4em; + margin: 0 0 0.25em; + font-size: 1.1em; + + svg { + height: 1em; + } +} + +.detailFeature { + margin: 0 0 0.5em; + opacity: 0.75; +} + +.detailMeta { + display: flex; + flex-wrap: wrap; + gap: 1em; + margin: 0.75em 0 0; + + dt { + font-size: 0.75em; + text-transform: uppercase; + opacity: 0.75; + } + + dd { + margin: 0; + } +} \ No newline at end of file diff --git a/src/components/app/Timeline.stories.tsx b/src/components/app/Timeline.stories.tsx new file mode 100644 index 00000000..d746ce5d --- /dev/null +++ b/src/components/app/Timeline.stories.tsx @@ -0,0 +1,40 @@ +import type { Envelope } from '@cucumber/messages' +import type { Story } from '@ladle/react' +import examplesTables from '../../../acceptance/examples-tables/examples-tables.js' +import parallel from '../../../acceptance/parallel/parallel.js' +import { EnvelopesProvider } from './EnvelopesProvider.js' +import { InMemorySearchProvider } from './InMemorySearchProvider.js' +import { Timeline } from './Timeline.js' + +export default { + title: 'App/Timeline', +} + +type TemplateArgs = { + envelopes: readonly Envelope[] +} + +const Template: Story = ({ envelopes }) => { + return ( + + + + + + ) +} + +export const SingleProcess = Template.bind({}) +SingleProcess.args = { + envelopes: examplesTables, +} as TemplateArgs + +export const Parallel = Template.bind({}) +Parallel.args = { + envelopes: parallel, +} as TemplateArgs + +export const NoTestCases = Template.bind({}) +NoTestCases.args = { + envelopes: [], +} as TemplateArgs diff --git a/src/components/app/Timeline.tsx b/src/components/app/Timeline.tsx new file mode 100644 index 00000000..ec6a1cbd --- /dev/null +++ b/src/components/app/Timeline.tsx @@ -0,0 +1,390 @@ +import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { type FC, useEffect, useMemo, useRef, useState } from 'react' +import { Button, Tooltip, TooltipTrigger } from 'react-aria-components' +import { useDebouncedCallback } from 'use-debounce' +import { formatExecutionDuration } from '../../formatExecutionDuration.js' +import { type TimelineItem, useTimelineData } from '../../hooks/useTimelineData.js' +import { StatusIcon } from '../gherkin/StatusIcon.js' +import statusName from '../gherkin/statusName.js' +import { Tags } from '../gherkin/Tags.js' +import { TestCaseOutcome } from '../results/index.js' +import styles from './Timeline.module.scss' + +type Unit = { + label: string + magnitude: number +} + +const pxPerUnit = 2 + +const AXIS_CONFIG = { + minorInterval: 5, // Minor tick every N × magnitude ms + majorInterval: 50, // Major tick every N × magnitude ms (must be a multiple of minorInterval) + PANNING_SPEED: 5, +} + +const axisUnits: Unit[] = [ + { label: `${1 * AXIS_CONFIG.minorInterval} ms`, magnitude: 1 }, + { label: `${10 * AXIS_CONFIG.minorInterval} ms`, magnitude: 10 }, + { label: `${50 * AXIS_CONFIG.minorInterval} ms`, magnitude: 50 }, + { label: `${100 * AXIS_CONFIG.minorInterval} ms`, magnitude: 100 }, + { label: `${500 * AXIS_CONFIG.minorInterval} ms`, magnitude: 500 }, + { label: `${1 * AXIS_CONFIG.minorInterval} s`, magnitude: 1 * 1000 }, + { label: `${10 * AXIS_CONFIG.minorInterval} s`, magnitude: 10 * 1000 }, + { label: `${30 * AXIS_CONFIG.minorInterval} s`, magnitude: 30 * 1000 }, + { label: `${1 * AXIS_CONFIG.minorInterval} min`, magnitude: 1 * 60 * 1000 }, + { label: `${10 * AXIS_CONFIG.minorInterval} min`, magnitude: 10 * 60 * 1000 }, + { label: `${30 * AXIS_CONFIG.minorInterval} min`, magnitude: 30 * 60 * 1000 }, + { label: `${1 * AXIS_CONFIG.minorInterval} hr`, magnitude: 1 * 60 * 60 * 1000 }, + { label: `${5 * AXIS_CONFIG.minorInterval} hr`, magnitude: 5 * 60 * 60 * 1000 }, + { label: `${10 * AXIS_CONFIG.minorInterval} hr`, magnitude: 10 * 60 * 60 * 1000 }, + { label: `${15 * AXIS_CONFIG.minorInterval} hr`, magnitude: 15 * 60 * 60 * 1000 }, + { label: `${20 * AXIS_CONFIG.minorInterval} hr`, magnitude: 20 * 60 * 60 * 1000 }, + { label: `${24 * AXIS_CONFIG.minorInterval} hr`, magnitude: 24 * 60 * 60 * 1000 }, +] +export const Timeline: FC = () => { + const { groups, fullStart, fullEnd } = useTimelineData() + const [selectedId, setSelectedId] = useState(undefined) + const [currentUnitIndex, setCurrentUnitIndex] = useState(0) + + const timelineWrapperRef = useRef(null) + const axisStartRef = useRef(fullStart) + const axisUnitRef = useRef(0) + + const selectedItem = useMemo(() => { + if (!selectedId) { + return null + } + + for (const grp of groups) { + const found = grp.items.find((item) => item.id === selectedId) + if (found) { + return found + } + } + + return null + }, [groups, selectedId]) + + useEffect(() => { + if (timelineWrapperRef.current) { + timelineWrapperRef.current.style.setProperty( + '--magnitude', + axisUnits[axisUnitRef.current].magnitude.toString() + ) + timelineWrapperRef.current.style.setProperty('--axis-start', fullStart.toString()) + } + }, [fullStart]) + + return ( + <> +
+ + + {groups.map((grp) => { + return ( +
+
{grp.label}
+ +
+ {bucketItems(grp.items, axisUnits[axisUnitRef.current].magnitude).map((itemIds) => { + return ( + + ) + })} +
+
+ ) + })} +
+ {selectedItem && ( + setSelectedId(undefined)} /> + )} + + ) +} + +const TimelineAxis: FC<{ + fullStart: number + fullEnd: number + currentUnitIndex: number + setCurrentUnitIndex: React.Dispatch> + axisStartRef: React.RefObject + timelineWrapperRef: React.RefObject + axisUnitRef: React.RefObject +}> = ({ + fullStart, + fullEnd, + currentUnitIndex, + setCurrentUnitIndex, + axisStartRef, + timelineWrapperRef, + axisUnitRef, +}) => { + // const [currentUnitIndex, setCurrentUnitIndex] = useState(0) + + const axisRef = useRef(null) + const isDragging = useRef(false) + + useEffect(() => { + axisStartRef.current = fullStart + axisUnitRef.current = 0 + setCurrentUnitIndex(0) + }, [axisStartRef, fullStart, axisUnitRef, setCurrentUnitIndex]) + + const checkAxisStartBounds = (magnitude: number) => { + axisStartRef.current = Math.min( + fullEnd + AXIS_CONFIG.majorInterval * magnitude, + axisStartRef.current + ) + axisStartRef.current = Math.max( + fullStart - AXIS_CONFIG.majorInterval * magnitude, + axisStartRef.current + ) + } + + const handleAxisZoom = useDebouncedCallback((deltaY: number) => { + if (!timelineWrapperRef?.current) { + return + } + const zoomDirection = deltaY < 0 ? -1 : 1 + + const newIndex = + zoomDirection === 1 + ? Math.min(axisUnits.length - 1, axisUnitRef.current + 1) + : Math.max(0, axisUnitRef.current - 1) + + axisUnitRef.current = newIndex + timelineWrapperRef.current.style.setProperty( + '--magnitude', + axisUnits[newIndex].magnitude.toString() + ) + + // Check axis start for new zoom level + checkAxisStartBounds(axisUnits[newIndex].magnitude) + timelineWrapperRef.current.style.setProperty('--axis-start', axisStartRef.current.toString()) + + setCurrentUnitIndex(newIndex) + }, 100) + + useEffect(() => { + const element = axisRef.current + if (!element) { + return + } + + const onWheel = (event: WheelEvent) => { + event.preventDefault() + handleAxisZoom(event.deltaY) + } + + element.addEventListener('wheel', onWheel, { passive: false }) + return () => { + element.removeEventListener('wheel', onWheel) + handleAxisZoom.cancel() + } + }, [handleAxisZoom]) + + const handleAxisPanning = (deltaX: number) => { + if (!isDragging.current || !timelineWrapperRef?.current) { + return + } + + const newStart = + deltaX < 0 + ? axisStartRef.current + + axisUnits[axisUnitRef.current].magnitude * AXIS_CONFIG.PANNING_SPEED + : axisStartRef.current - + axisUnits[axisUnitRef.current].magnitude * AXIS_CONFIG.PANNING_SPEED + + axisStartRef.current = newStart + checkAxisStartBounds(axisUnits[axisUnitRef.current].magnitude) + + timelineWrapperRef.current.style.setProperty('--axis-start', newStart.toString()) + } + + const ticks = useMemo(() => { + const magnitude = axisUnits[currentUnitIndex].magnitude + const minorInterval = AXIS_CONFIG.minorInterval * magnitude + const minorStepsPerMajor = AXIS_CONFIG.majorInterval / AXIS_CONFIG.minorInterval + + const firstK = Math.floor(fullStart / minorInterval) + const lastK = Math.ceil(fullEnd / minorInterval) + + const result: Array<{ time: number; isMajor: boolean }> = [] + for (let k = firstK; k <= lastK; k++) { + const time = k * minorInterval + const isMajor = k % minorStepsPerMajor === 0 + result.push({ time, isMajor }) + } + return result + }, [currentUnitIndex, fullStart, fullEnd]) + + return ( +
+
+ 1 Minor Tick = {axisUnits[currentUnitIndex].label} +
+ + +
+ ) +} + +const TimelineBar: FC<{ + items: TimelineItem[] + selectedId: string | undefined + setSelectedId: (id: string | undefined) => void +}> = ({ items, selectedId, setSelectedId }) => { + const start = items.reduce((acc, item) => Math.min(acc, item.start), Number.MAX_SAFE_INTEGER) + const end = items.reduce((acc, item) => Math.max(acc, item.end), Number.MIN_SAFE_INTEGER) + const status = items.length === 1 ? items[0].status : 'UNKNOWN' + + return ( + items.length && ( + + + + {items.map((item) => { + return ( + + ) + })} + + + ) + ) +} + +const TimelineDetail: FC<{ item: TimelineItem; onClose: () => void }> = ({ item, onClose }) => { + return ( +
+ +

+ + {item.scenario} +

+ {item.feature &&

{item.feature}

} + +
+
+
Status
+
{statusName(item.status)}
+
+
+
Start
+
{formatTime(item.start)}
+
+
+
End
+
{formatTime(item.end)}
+
+
+
Duration
+
{formatExecutionDuration(new Date(item.start), new Date(item.end))}
+
+
+
Worker
+
{item.groupLabel}
+
+
+ +
+ ) +} + +function formatTime(time: number): string { + const d = new Date(time) + const formattedTime = `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}:${d.getMilliseconds()}` + return formattedTime +} + +function bucketItems(items: TimelineItem[], minDuration: number) { + const result: number[][] = [] + + let i = 0 + while (i < items.length) { + const bucket: number[] = [] + let bucketDuration = 0 + + do { + bucket.push(i) + bucketDuration += items[i].end - items[i].start + 1 + i++ + } while (i < items.length && bucketDuration < minDuration) + + if (bucketDuration >= minDuration) { + result.push(bucket) + } else { + // Try merging with left + if (result.length > 0) { + result[result.length - 1].push(...bucket) + } else { + // No adjacent bucket exist + result.push(bucket) + } + } + } + + return result +} diff --git a/src/components/app/index.ts b/src/components/app/index.ts index 890c590d..b6399b7b 100644 --- a/src/components/app/index.ts +++ b/src/components/app/index.ts @@ -11,4 +11,5 @@ export * from './Report.js' export * from './SearchBar.js' export * from './StatusesSummary.js' export * from './TestRunHooks.js' +export * from './Timeline.js' export * from './UrlSearchProvider.js' diff --git a/src/hooks/useTimelineData.ts b/src/hooks/useTimelineData.ts new file mode 100644 index 00000000..90fa5331 --- /dev/null +++ b/src/hooks/useTimelineData.ts @@ -0,0 +1,127 @@ +import { + type PickleTag, + type TestCaseStarted, + TestStepResultStatus, + TimeConversion, +} from '@cucumber/messages' +import { useMemo } from 'react' +import { useFilteredTestCases } from './useFilteredTestCases.js' +import { useQueries } from './useQueries.js' + +export interface TimelineItem { + readonly id: string + readonly groupId: string + readonly groupLabel: string + readonly feature: string + readonly scenario: string + readonly tags: readonly PickleTag[] + readonly status: TestStepResultStatus + readonly start: number + readonly end: number + readonly testCaseStarted: TestCaseStarted +} + +export interface TimelineGroup { + readonly id: string + readonly label: string + readonly items: TimelineItem[] +} + +export interface TimelineData { + readonly groups: readonly TimelineGroup[] + readonly fullStart: number + readonly fullEnd: number +} + +const UNASSIGNED_GROUP_ID = '' + +export function useTimelineData(): TimelineData { + const { cucumberQuery } = useQueries() + + const finishedTestCases = useFilteredTestCases() + return useMemo(() => { + const groupMap: Record = {} + let fullStart: number = Number.MAX_SAFE_INTEGER + let fullEnd: number = Number.MIN_SAFE_INTEGER + + for (const testCaseFinished of finishedTestCases) { + const testCaseStarted = cucumberQuery.findTestCaseStartedBy(testCaseFinished.testCaseEvent) + if (!testCaseStarted) { + continue + } + const pickle = testCaseFinished.pickle + if (!pickle) { + continue + } + + const itemStart = TimeConversion.timestampToMillisecondsSinceEpoch(testCaseStarted.timestamp) + const itemEnd = TimeConversion.timestampToMillisecondsSinceEpoch( + testCaseFinished.testCaseEvent.timestamp + ) + + if (itemStart < fullStart) { + fullStart = itemStart + } + if (itemEnd > fullEnd) { + fullEnd = itemEnd + } + + // A test case with no step results at all is considered passed by definition + const status = + cucumberQuery.findMostSevereTestStepResultBy(testCaseFinished.testCaseEvent)?.status ?? + TestStepResultStatus.PASSED + + const feature = testCaseFinished.lineage.feature?.name ?? '' + const scenario = pickle.name + + const groupId = testCaseStarted.workerId ?? UNASSIGNED_GROUP_ID + + const item = { + id: testCaseStarted.id, + groupId, + groupLabel: describeGroup(groupId), + feature, + scenario, + tags: pickle.tags, + status, + start: itemStart, + end: itemEnd, + testCaseStarted, + } + + if (groupMap[groupId]) { + groupMap[groupId].items.push(item) + } else { + groupMap[groupId] = { id: groupId, label: describeGroup(groupId), items: [item] } + } + } + + for (const grp of Object.values(groupMap)) { + grp.items.sort((a, b) => a.start - b.start || a.end - b.end) + } + + const groups: TimelineGroup[] = Object.values(groupMap) + groups.sort((a, b) => compareGroupIds(a.id, b.id)) + + return { groups, fullStart, fullEnd } + }, [cucumberQuery, finishedTestCases]) +} + +function describeGroup(id: string): string { + return id === UNASSIGNED_GROUP_ID ? '' : `Worker ${id}` +} + +function compareGroupIds(a: string, b: string): number { + if (a === UNASSIGNED_GROUP_ID) { + return -1 + } + if (b === UNASSIGNED_GROUP_ID) { + return 1 + } + const aNum = Number(a) + const bNum = Number(b) + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { + return aNum - bNum + } + return a.localeCompare(b) +}