diff --git a/CHANGELOG.md b/CHANGELOG.md index 80bd6c78..84d10cf7 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 ported from cucumber-jvm's TimelineFormatter ([#126](https://github.com/cucumber/react-components/issues/126)) diff --git a/package-lock.json b/package-lock.json index 10d6a7a1..fe74a55a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,9 @@ "rehype-sanitize": "6.0.0", "remark-breaks": "4.0.0", "remark-gfm": "4.0.1", - "use-debounce": "^10.0.0" + "use-debounce": "^10.0.0", + "vis-data": "^8.0.4", + "vis-timeline": "^8.5.1" }, "devDependencies": { "@biomejs/biome": "^2.4.1", @@ -924,6 +926,19 @@ "integrity": "sha512-uap3XSQFxj5HYAHQIShGeS2zotMEnUmnEVjyuhp39j7tDUvaU64ArHzRkLPSWRavEI8ycdeNdwef1pcM/n6pSQ==", "license": "MIT" }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", @@ -5266,6 +5281,13 @@ "glob": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/highlight-words-core": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/highlight-words-core/-/highlight-words-core-1.2.3.tgz", @@ -6227,6 +6249,16 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6440,6 +6472,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT", + "peer": true + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -8884,6 +8923,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/keycharm": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz", + "integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==", + "license": "(Apache-2.0 OR MIT)", + "peer": true + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -10891,6 +10937,16 @@ "node": ">=12" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11697,6 +11753,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/propagating-hammerjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/propagating-hammerjs/-/propagating-hammerjs-3.0.0.tgz", + "integrity": "sha512-FJTclGll0ysatpF9rKO4jwobyaVDitPb0g/bGlufqqtXPQX8mxf8IXilnIK2iYRMPkVlYeFNhPTrspF9CM1stg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.17" + } + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -13671,6 +13737,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -13687,6 +13767,59 @@ "node": ">= 0.8" } }, + "node_modules/vis-data": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-8.0.4.tgz", + "integrity": "sha512-TsN0sMHqIRpdfg6TNPtfdINpkgxtnQP6JNWCaiSwvou5seXqKiP5eERkaBg+Y56wyJ4FZTeOEs/dEmWEPrpltQ==", + "license": "(Apache-2.0 OR MIT)", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0", + "vis-util": ">=6.0.0" + } + }, + "node_modules/vis-timeline": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/vis-timeline/-/vis-timeline-8.5.1.tgz", + "integrity": "sha512-6pqx4Zl/xHCEy5nXRaz9xCOx1HtZWkxSIt1oHJmDZy/UxT09kwq1eA4eKRAzhHey3PN1Ee3DsxuxHm7yI2G2mQ==", + "license": "(Apache-2.0 OR MIT)", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0", + "keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0", + "moment": "^2.24.0", + "propagating-hammerjs": "^1.4.0 || ^2.0.0 || ^3.0.0", + "uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^13.0.0 || ^14.0.0", + "vis-data": ">=8.0.0", + "vis-util": ">=6.0.0", + "xss": "^1.0.0" + } + }, + "node_modules/vis-util": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/vis-util/-/vis-util-6.0.0.tgz", + "integrity": "sha512-qtpts3HRma0zPe4bO7t9A2uejkRNj8Z2Tb6do6lN85iPNWExFkUiVhdAq5uLGIUqBFduyYeqWJKv/jMkxX0R5g==", + "license": "(Apache-2.0 OR MIT)", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/visjs" + }, + "peerDependencies": { + "@egjs/hammerjs": "^2.0.0", + "component-emitter": "^1.3.0 || ^2.0.0" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -13803,22 +13936,6 @@ } } }, - "node_modules/vite-tsconfig-paths/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/vite/node_modules/fdir": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", @@ -14090,6 +14207,30 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "peer": true, + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 5a273935..4dbd079a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,9 @@ "rehype-sanitize": "6.0.0", "remark-breaks": "4.0.0", "remark-gfm": "4.0.1", - "use-debounce": "^10.0.0" + "use-debounce": "^10.0.0", + "vis-data": "^8.0.4", + "vis-timeline": "^8.5.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", 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..a8e4f64c --- /dev/null +++ b/src/components/app/Timeline.module.scss @@ -0,0 +1,76 @@ +@use '../../styles/statuses'; +@use '../../styles/theming'; + +.container { + border: 1px solid theming.$panelAccentColor; +} + +@each $name, $color in statuses.$statusColors { + .visItem[data-status='#{$name}'] { + background-color: $color; + border-color: $color; + } + +.visItem[data-status='#{$name}']:global(.vis-selected) { + background-color: $color; + border-color: $color; + + outline: 2px solid theming.$panelTextColor; + outline-offset: 1px; + z-index: 2; + } +} + +.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.spec.tsx b/src/components/app/Timeline.spec.tsx new file mode 100644 index 00000000..dfdeeb23 --- /dev/null +++ b/src/components/app/Timeline.spec.tsx @@ -0,0 +1,136 @@ +import { type Envelope, type TestCaseStarted, TestStepResultStatus } from '@cucumber/messages' +import { render, screen, within } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { expect } from 'chai' + +import examplesTablesFeature from '../../../acceptance/examples-tables/examples-tables.js' +import { ControlledSearchProvider } from './ControlledSearchProvider.js' +import { EnvelopesProvider } from './EnvelopesProvider.js' +import { Timeline } from './Timeline.js' + +describe('', () => { + it('should show a message when no scenarios were executed', () => { + render( + + {}}> + + + + ) + + expect(screen.getByText('No scenarios were executed.')).to.be.visible + }) + + it('should show a message when filters exclude every scenario', () => { + render( + + {}} + > + + + + ) + + expect(screen.getByText('No scenarios match your query and/or filters.')).to.be.visible + }) + + it('should render one bar per executed scenario, in a single lane when no worker information is present', () => { + render( + + {}}> + + + + ) + + expect(screen.getByText('Main process')).to.be.visible + expect(screen.getAllByRole('button')).to.have.length(7) + }) + + it('should respect the hideStatuses filter from the shared search context', () => { + render( + + {}} + > + + + + ) + + expect(screen.getAllByRole('button')).to.have.length(5) + }) + + it('should respect a tag expression from the shared search context', () => { + render( + + {}} + > + + + + ) + + expect(screen.getAllByRole('button')).to.have.length(2) + }) + + it('should group test cases by worker id, sorted numerically', () => { + render( + + {}}> + + + + ) + + expect(screen.getAllByTestId('cucumber.timeline.group')).to.have.length(2) + expect(screen.getByText('Worker 0')).to.be.visible + expect(screen.getByText('Worker 1')).to.be.visible + }) + + it('should show scenario details when a bar is selected, and hide them again on close', async () => { + render( + + {}}> + + + + ) + + expect(screen.queryByTestId('cucumber.timeline.detail')).to.be.null + + await userEvent.click(screen.getByRole('button', { name: 'Eating cucumbers with 11 friends' })) + + const detail = screen.getByTestId('cucumber.timeline.detail') + expect(within(detail).getByText('Eating cucumbers with 11 friends')).to.be.visible + expect(within(detail).getByText('Examples Tables')).to.be.visible + + await userEvent.click(within(detail).getByRole('button', { name: 'Close' })) + + expect(screen.queryByTestId('cucumber.timeline.detail')).to.be.null + }) +}) + +function distributeAcrossWorkers( + envelopes: ReadonlyArray, + workerCount: number +): ReadonlyArray { + let index = 0 + return envelopes.map((envelope): Envelope => { + if (!envelope.testCaseStarted) { + return envelope + } + const workerId = String(index % workerCount) + index += 1 + const testCaseStarted: TestCaseStarted = { ...envelope.testCaseStarted, workerId } + return { ...envelope, testCaseStarted } + }) +} 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..9c501bbe --- /dev/null +++ b/src/components/app/Timeline.tsx @@ -0,0 +1,132 @@ +import { type FC, useEffect, useRef, useState } from 'react' +import { DataSet } from 'vis-data' +import { Timeline as VisTimeline } from 'vis-timeline' +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' + +import 'vis-timeline/styles/vis-timeline-graph2d.css' +import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +type DataSetGroup = { + id: string + content: string +} + +type DataSetItem = { + id: string + content: string + group: string + start: Date + end: Date + status: string + className: string +} +export const Timeline: FC = () => { + const { groups, items, fullStart, fullEnd, filtered } = useTimelineData() + const [selectedId, setSelectedId] = useState() + + const containerRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) { + return + } + + const dataSetGroups = new DataSet() + groups.forEach((g) => { + dataSetGroups.add({ id: g.id, content: g.label }) + }) + + const dataSetItems = new DataSet() + items.forEach((i) => { + dataSetItems.add({ + id: i.id, + content: i.scenario, + group: i.groupId, + start: new Date(i.start), + end: new Date(i.end), + status: i.status, + className: styles.visItem, + }) + }) + + const timeline = new VisTimeline(containerRef.current, dataSetItems, dataSetGroups, { + stack: false, + zoomable: true, + moveable: true, + selectable: true, + editable: false, + showCurrentTime: false, + orientation: 'top', + min: fullStart, + max: fullEnd, + start: fullStart, + end: fullEnd, + dataAttributes: ['status'], + }) + + timeline.on('select', (props: { items: string[] }) => { + setSelectedId(props.items[0] ?? undefined) + }) + + return () => { + timeline.destroy() + } + }, [fullStart, fullEnd, groups, items]) + + if (items.length === 0) { + return filtered ? ( +

No scenarios match your query and/or filters.

+ ) : ( +

No scenarios were executed.

+ ) + } + + const selectedItem = items.find((item) => item.id === selectedId) + + return ( +
+
+ {selectedItem && ( + setSelectedId(undefined)} /> + )} +
+ ) +} + +const TimelineDetail: FC<{ item: TimelineItem; onClose: () => void }> = ({ item, onClose }) => { + return ( +
+ +

+ + {item.scenario} +

+ {item.feature &&

{item.feature}

} + +
+
+
Status
+
{statusName(item.status)}
+
+
+
Duration
+
{formatExecutionDuration(new Date(item.start), new Date(item.end))}
+
+
+
Worker
+
{item.groupLabel}
+
+
+ +
+ ) +} 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/custom.d.ts b/src/custom.d.ts index e48f665b..14a684e2 100644 --- a/src/custom.d.ts +++ b/src/custom.d.ts @@ -2,3 +2,5 @@ declare module '*.module.scss' { const classes: { [key: string]: string } export default classes } + +declare module '*.css' diff --git a/src/hooks/useTimelineData.ts b/src/hooks/useTimelineData.ts new file mode 100644 index 00000000..16ef807e --- /dev/null +++ b/src/hooks/useTimelineData.ts @@ -0,0 +1,141 @@ +import { + type PickleTag, + type TestCaseStarted, + TestStepResultStatus, + TimeConversion, +} from '@cucumber/messages' +import { useMemo } from 'react' + +import { useQueries } from './useQueries.js' +import { useSearch } from './useSearch.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 +} + +export interface TimelineData { + readonly groups: readonly TimelineGroup[] + readonly items: readonly TimelineItem[] + readonly fullStart: number | undefined + readonly fullEnd: number | undefined + readonly filtered: boolean +} + +const UNASSIGNED_GROUP_ID = '' + +export function useTimelineData(): TimelineData { + const { cucumberQuery } = useQueries() + const { hideStatuses, tagExpression, searchTerm, unchanged } = useSearch() + + return useMemo(() => { + const items: TimelineItem[] = [] + const groupIds = new Set() + const normalizedSearchTerm = searchTerm?.trim().toLowerCase() + let fullStart: number | undefined + let fullEnd: number | undefined + + for (const testCaseFinished of cucumberQuery.findAllTestCaseFinished()) { + const testCaseStarted = cucumberQuery.findTestCaseStartedBy(testCaseFinished) + if (!testCaseStarted) { + continue + } + const pickle = cucumberQuery.findPickleBy(testCaseStarted) + if (!pickle) { + continue + } + + const itemStart = TimeConversion.timestampToMillisecondsSinceEpoch(testCaseStarted.timestamp) + const itemEnd = TimeConversion.timestampToMillisecondsSinceEpoch(testCaseFinished.timestamp) + + if (fullStart === undefined || itemStart < fullStart) { + fullStart = itemStart + } + if (fullEnd === undefined || itemEnd > fullEnd) { + fullEnd = itemEnd + } + + // A test case with no step results at all is considered passed by definition + const status = + cucumberQuery.findMostSevereTestStepResultBy(testCaseFinished)?.status ?? + TestStepResultStatus.PASSED + + if (hideStatuses.includes(status)) { + continue + } + + if (tagExpression) { + const tagNames = pickle.tags.map((tag) => tag.name) + if (!tagExpression.evaluate(tagNames)) { + continue + } + } + + const feature = cucumberQuery.findLineageBy(testCaseStarted)?.feature?.name ?? '' + const scenario = pickle.name + + if ( + normalizedSearchTerm && + !`${feature} ${scenario}`.toLowerCase().includes(normalizedSearchTerm) + ) { + continue + } + + const groupId = testCaseStarted.workerId ?? UNASSIGNED_GROUP_ID + groupIds.add(groupId) + + items.push({ + id: testCaseStarted.id, + groupId, + groupLabel: describeGroup(groupId), + feature, + scenario, + tags: pickle.tags, + status, + start: itemStart, + end: itemEnd, + testCaseStarted, + }) + } + + items.sort((a, b) => a.start - b.start || a.end - b.end) + + const groups: TimelineGroup[] = [...groupIds] + .sort(compareGroupIds) + .map((id) => ({ id, label: describeGroup(id) })) + + return { groups, items, fullStart, fullEnd, filtered: !unchanged } + }, [cucumberQuery, hideStatuses, tagExpression, searchTerm, unchanged]) +} + +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) +}