From cb8feff30bf2ddbf23d416312870e2453f7a466d Mon Sep 17 00:00:00 2001 From: davidjgoss Date: Fri, 21 Jun 2024 09:44:28 +0100 Subject: [PATCH] Add support for externalised attachments (#353) --- CHANGELOG.md | 2 + package-lock.json | 16 +- package.json | 1 + src/components/CucumberReact.tsx | 2 +- src/components/customise/customRendering.tsx | 2 +- src/components/gherkin/Attachment.tsx | 184 ------------------ src/components/gherkin/GherkinStep.tsx | 2 +- src/components/gherkin/HookStep.tsx | 2 +- .../{ => attachment}/Attachment.module.scss | 18 +- .../{ => attachment}/Attachment.spec.tsx | 51 ++++- .../gherkin/attachment/Attachment.stories.tsx | 78 ++++++++ .../gherkin/attachment/Attachment.tsx | 56 ++++++ src/components/gherkin/attachment/Image.tsx | 42 ++++ src/components/gherkin/attachment/Log.tsx | 22 +++ src/components/gherkin/attachment/Text.tsx | 35 ++++ src/components/gherkin/attachment/Unknown.tsx | 81 ++++++++ src/components/gherkin/attachment/Video.tsx | 33 ++++ .../attachmentFilename.spec.ts | 0 .../{ => attachment}/attachmentFilename.ts | 0 .../gherkin/attachment/base64Decode.ts | 4 + .../gherkin/attachment/fixture-image.svg | 15 ++ .../gherkin/attachment/fixture-text.json | 4 + src/components/gherkin/attachment/index.ts | 1 + src/components/gherkin/attachment/useText.ts | 32 +++ src/components/gherkin/index.ts | 2 +- 25 files changed, 485 insertions(+), 200 deletions(-) delete mode 100644 src/components/gherkin/Attachment.tsx rename src/components/gherkin/{ => attachment}/Attachment.module.scss (54%) rename src/components/gherkin/{ => attachment}/Attachment.spec.tsx (75%) create mode 100644 src/components/gherkin/attachment/Attachment.stories.tsx create mode 100644 src/components/gherkin/attachment/Attachment.tsx create mode 100644 src/components/gherkin/attachment/Image.tsx create mode 100644 src/components/gherkin/attachment/Log.tsx create mode 100644 src/components/gherkin/attachment/Text.tsx create mode 100644 src/components/gherkin/attachment/Unknown.tsx create mode 100644 src/components/gherkin/attachment/Video.tsx rename src/components/gherkin/{ => attachment}/attachmentFilename.spec.ts (100%) rename src/components/gherkin/{ => attachment}/attachmentFilename.ts (100%) create mode 100644 src/components/gherkin/attachment/base64Decode.ts create mode 100644 src/components/gherkin/attachment/fixture-image.svg create mode 100644 src/components/gherkin/attachment/fixture-text.json create mode 100644 src/components/gherkin/attachment/index.ts create mode 100644 src/components/gherkin/attachment/useText.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f26373..9b66a9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Support for externalised attachments ([#353](https://github.com/cucumber/react-components/pull/353)) ## [22.1.0] - 2024-03-15 ### Added diff --git a/package-lock.json b/package-lock.json index 5e23d8c1..6930d219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "highlight-words": "1.2.2", "mime": "^3.0.0", "react-accessible-accordion": "5.0.0", + "react-error-boundary": "^4.0.13", "react-markdown": "6.0.3", "rehype-raw": "5.1.0", "rehype-sanitize": "4.0.0", @@ -1794,7 +1795,6 @@ "version": "7.20.1", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.1.tgz", "integrity": "sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.10" }, @@ -10225,6 +10225,17 @@ "react": "^18.2.0" } }, + "node_modules/react-error-boundary": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", + "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-frame-component": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/react-frame-component/-/react-frame-component-5.2.3.tgz", @@ -10396,8 +10407,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.10", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", - "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==", - "dev": true + "integrity": "sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==" }, "node_modules/regenerator-transform": { "version": "0.15.0", diff --git a/package.json b/package.json index 6b9fe84d..d7527844 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "highlight-words": "1.2.2", "mime": "^3.0.0", "react-accessible-accordion": "5.0.0", + "react-error-boundary": "^4.0.13", "react-markdown": "6.0.3", "rehype-raw": "5.1.0", "rehype-sanitize": "4.0.0", diff --git a/src/components/CucumberReact.tsx b/src/components/CucumberReact.tsx index 9c61942d..96bb49ad 100644 --- a/src/components/CucumberReact.tsx +++ b/src/components/CucumberReact.tsx @@ -11,7 +11,7 @@ interface IProps { export const CucumberReact: FunctionComponent> = ({ children, - theme = 'light', + theme = 'auto', customRendering = {}, className, }) => { diff --git a/src/components/customise/customRendering.tsx b/src/components/customise/customRendering.tsx index 4af536b9..4a1a4c44 100644 --- a/src/components/customise/customRendering.tsx +++ b/src/components/customise/customRendering.tsx @@ -31,7 +31,7 @@ export interface AttachmentProps { attachment: messages.Attachment } -export type AttachmentClasses = Styles<'text' | 'icon' | 'image'> +export type AttachmentClasses = Styles<'text' | 'log' | 'icon' | 'image'> export interface BackgroundProps { background: messages.Background diff --git a/src/components/gherkin/Attachment.tsx b/src/components/gherkin/Attachment.tsx deleted file mode 100644 index ed5229c2..00000000 --- a/src/components/gherkin/Attachment.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import * as messages from '@cucumber/messages' -import { AttachmentContentEncoding } from '@cucumber/messages' -import { faPaperclip } from '@fortawesome/free-solid-svg-icons' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -// @ts-ignore -import Convert from 'ansi-to-html' -import React, { FC, useCallback, useEffect, useState } from 'react' - -import { NavigationButton } from '../app/NavigationButton.js' -import { - AttachmentClasses, - AttachmentProps, - DefaultComponent, - useCustomRendering, -} from '../customise/index.js' -import defaultStyles from './Attachment.module.scss' -import { attachmentFilename } from './attachmentFilename.js' -import { ErrorMessage } from './ErrorMessage.js' - -export const DefaultRenderer: DefaultComponent = ({ - attachment, - styles, -}) => { - if (attachment.mediaType.match(/^image\//)) { - return image(attachment, styles) - } else if (attachment.mediaType.match(/^video\//)) { - return video(attachment) - } else if (attachment.mediaType == 'text/x.cucumber.log+plain') { - return text(attachment, prettyANSI, true, styles) - } else if (attachment.mediaType.match(/^text\//)) { - return text(attachment, (s) => s, false, styles) - } else if (attachment.mediaType.match(/^application\/json/)) { - return text(attachment, prettyJSON, false, styles) - } else { - return - } -} - -export const Attachment: React.FunctionComponent = (props) => { - const ResolvedRenderer = useCustomRendering( - 'Attachment', - defaultStyles, - DefaultRenderer - ) - return -} - -const Unknown: FC = ({ attachment }) => { - const [downloadUrl, setDownloadUrl] = useState() - useEffect(() => () => cleanupDownloadUrl(downloadUrl), [downloadUrl]) - const filename = attachmentFilename(attachment) - const onClick = useCallback(() => { - let href - if (downloadUrl) { - href = downloadUrl - } else { - const createdUrl = createDownloadUrl(attachment) - setDownloadUrl(createdUrl) - href = createdUrl - } - - const anchor = document.createElement('a') - anchor.href = href - anchor.download = filename - anchor.click() - }, [attachment, filename, downloadUrl]) - return ( - - - Download {filename} - - ) -} - -function createDownloadUrl(attachment: messages.Attachment) { - console.debug('Creating download url') - const body = - attachment.contentEncoding === AttachmentContentEncoding.BASE64 - ? base64Decode(attachment.body) - : attachment.body - const bytes = Uint8Array.from(body, (m) => m.codePointAt(0) as number) - const file = new File([bytes], 'attachment', { - type: attachment.mediaType, - }) - return URL.createObjectURL(file) -} - -function cleanupDownloadUrl(url?: string) { - if (url) { - console.debug('Revoking download url') - URL.revokeObjectURL(url) - } -} - -function image(attachment: messages.Attachment, classes: AttachmentClasses) { - if (attachment.contentEncoding !== 'BASE64') { - return ( - - ) - } - - const attachmentTitle = attachment.fileName ?? 'Attached Image (' + attachment.mediaType + ')' - - return ( -
- {attachmentTitle} - Embedded Image -
- ) -} - -function video(attachment: messages.Attachment) { - const attachmentTitle = attachment.fileName ?? 'Attached Video (' + attachment.mediaType + ')' - - if (attachment.contentEncoding !== 'BASE64') { - return ( - - ) - } - return ( -
- {attachmentTitle} - -
- ) -} - -function base64Decode(body: string) { - return atob(body) -} - -function text( - attachment: messages.Attachment, - prettify: (body: string) => string, - dangerouslySetInnerHTML: boolean, - classes: AttachmentClasses -) { - const body = - attachment.contentEncoding === 'IDENTITY' ? attachment.body : base64Decode(attachment.body) - - const attachmentTitle = attachment.fileName ?? 'Attached Text (' + attachment.mediaType + ')' - - if (dangerouslySetInnerHTML) { - return ( -
- {attachmentTitle} -
-          
-        
-
- ) - } - return ( -
- {attachmentTitle} -
-        {prettify(body)}
-      
-
- ) -} - -function prettyJSON(s: string) { - try { - return JSON.stringify(JSON.parse(s), null, 2) - } catch (ignore) { - return s - } -} - -function prettyANSI(s: string) { - return new Convert().toHtml(s) -} diff --git a/src/components/gherkin/GherkinStep.tsx b/src/components/gherkin/GherkinStep.tsx index 20da6fe1..894d76e3 100644 --- a/src/components/gherkin/GherkinStep.tsx +++ b/src/components/gherkin/GherkinStep.tsx @@ -14,7 +14,7 @@ import GherkinQueryContext from '../../GherkinQueryContext.js' import { HighLight } from '../app/HighLight.js' import { DefaultComponent, GherkinStepProps, useCustomRendering } from '../customise/index.js' import { TestStepResultDetails } from '../results/index.js' -import { Attachment } from './Attachment.js' +import { Attachment } from './attachment/index.js' import { DataTable as DataTableComponent } from './DataTable.js' import { DocString as DocStringComponent } from './DocString.js' import { Keyword } from './Keyword.js' diff --git a/src/components/gherkin/HookStep.tsx b/src/components/gherkin/HookStep.tsx index 66e08009..3d522890 100644 --- a/src/components/gherkin/HookStep.tsx +++ b/src/components/gherkin/HookStep.tsx @@ -5,7 +5,7 @@ import React from 'react' import CucumberQueryContext from '../../CucumberQueryContext.js' import { HookStepProps, useCustomRendering } from '../customise/index.js' import { TestStepResultDetails } from '../results/index.js' -import { Attachment } from './Attachment.js' +import { Attachment } from './attachment/index.js' import { StepItem } from './StepItem.js' import { Title } from './Title.js' diff --git a/src/components/gherkin/Attachment.module.scss b/src/components/gherkin/attachment/Attachment.module.scss similarity index 54% rename from src/components/gherkin/Attachment.module.scss rename to src/components/gherkin/attachment/Attachment.module.scss index 7dfe1eff..8310ea6f 100644 --- a/src/components/gherkin/Attachment.module.scss +++ b/src/components/gherkin/attachment/Attachment.module.scss @@ -1,6 +1,7 @@ -@import '../../styles/theming'; +@import '../../../styles/theming'; .text { + position: relative; white-space: pre-wrap; font-family: $monoFamily; font-size: 0.875em; @@ -12,6 +13,21 @@ color: $codeTextColor; } +.log { + padding-left: 3.25em; + + &::before { + content: 'Log'; + position: absolute; + top: 0.666em; + left: 0.75em; + text-transform: uppercase; + font-weight: bold; + color: $parameterColor; + opacity: 0.75; + } +} + .icon { margin-right: 0.75em; opacity: 0.333; diff --git a/src/components/gherkin/Attachment.spec.tsx b/src/components/gherkin/attachment/Attachment.spec.tsx similarity index 75% rename from src/components/gherkin/Attachment.spec.tsx rename to src/components/gherkin/attachment/Attachment.spec.tsx index 4f9082b9..41fce80e 100644 --- a/src/components/gherkin/Attachment.spec.tsx +++ b/src/components/gherkin/attachment/Attachment.spec.tsx @@ -1,8 +1,9 @@ import * as messages from '@cucumber/messages' +import { AttachmentContentEncoding } from '@cucumber/messages' import { expect } from 'chai' import React from 'react' -import { render, screen } from '../../../test-utils/index.js' +import { render, screen } from '../../../../test-utils/index.js' import { Attachment } from './Attachment.js' describe('', () => { @@ -18,6 +19,18 @@ describe('', () => { expect(screen.getByRole('button', { name: 'Download document.pdf' })).to.be.visible }) + it('renders a download button for an unknown externalised attachment', () => { + const attachment: messages.Attachment = { + body: '', + mediaType: 'application/pdf', + contentEncoding: messages.AttachmentContentEncoding.IDENTITY, + fileName: 'document.pdf', + } + render() + + expect(screen.getByRole('button', { name: 'Download document.pdf' })).to.be.visible + }) + it('renders a video', () => { const attachment: messages.Attachment = { mediaType: 'video/mp4', @@ -45,6 +58,20 @@ describe('', () => { expect(video).to.have.attr('src', 'data:video/mp4;base64,fake-base64') }) + it('renders an externalised video', () => { + const attachment: messages.Attachment = { + mediaType: 'video/mp4', + body: '', + contentEncoding: messages.AttachmentContentEncoding.IDENTITY, + url: './path-to-video.mp4', + } + const { container } = render() + const summary = container.querySelector('details summary') + const video = container.querySelector('video source') + expect(summary).to.have.text('Attached Video (video/mp4)') + expect(video).to.have.attr('src', './path-to-video.mp4') + }) + it('renders an image', () => { const attachment: messages.Attachment = { mediaType: 'image/png', @@ -72,6 +99,20 @@ describe('', () => { expect(img).to.have.attr('src', 'data:image/png;base64,fake-base64') }) + it('renders an externalised image ', () => { + const attachment: messages.Attachment = { + mediaType: 'image/png', + body: '', + contentEncoding: AttachmentContentEncoding.IDENTITY, + url: './path-to-image.png', + } + const { container } = render() + const summary = container.querySelector('details summary') + const img = container.querySelector('img') + expect(summary).to.have.text('Attached Image (image/png)') + expect(img).to.have.attr('src', './path-to-image.png') + }) + it('renders base64 encoded plaintext', () => { const attachment: messages.Attachment = { mediaType: 'text/plain', @@ -106,9 +147,7 @@ describe('', () => { contentEncoding: messages.AttachmentContentEncoding.IDENTITY, } const { container } = render() - const summary = container.querySelector('details summary') - const data = container.querySelector('details > pre > span') - expect(summary).to.have.text('Attached Text (text/x.cucumber.log+plain)') + const data = container.querySelector('pre > span') expect(data).to.contain.html( 'blackwhite' ) @@ -122,9 +161,7 @@ describe('', () => { contentEncoding: messages.AttachmentContentEncoding.IDENTITY, } const { container } = render() - const summary = container.querySelector('details summary') - const data = container.querySelector('details > pre > span') - expect(summary).to.have.text('the attachment name') + const data = container.querySelector('pre > span') expect(data).to.contain.html( 'blackwhite' ) diff --git a/src/components/gherkin/attachment/Attachment.stories.tsx b/src/components/gherkin/attachment/Attachment.stories.tsx new file mode 100644 index 00000000..32b719c4 --- /dev/null +++ b/src/components/gherkin/attachment/Attachment.stories.tsx @@ -0,0 +1,78 @@ +import { AttachmentContentEncoding } from '@cucumber/messages' +import { Story } from '@ladle/react' +import React from 'react' + +import { CucumberReact } from '../../CucumberReact.js' +import { AttachmentProps } from '../../customise/index.js' +import { Attachment } from './Attachment.js' +// @ts-expect-error vite static asset import +import externalisedImageUrl from './fixture-image.svg?url' +// @ts-expect-error vite static asset import +import externalisedTextUrl from './fixture-text.json?url' + +export default { + title: 'Gherkin/Attachment', +} + +type TemplateArgs = AttachmentProps + +const Template: Story = ({ attachment }) => { + return ( + + + + ) +} + +export const Log = Template.bind({}) +Log.args = { + attachment: { + mediaType: 'text/x.cucumber.log+plain', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + }, +} satisfies AttachmentProps + +export const ExternalisedImage = Template.bind({}) +ExternalisedImage.storyName = 'Externalised image' +ExternalisedImage.args = { + attachment: { + mediaType: 'image/svg+xml', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: externalisedImageUrl, + }, +} satisfies AttachmentProps + +export const ExternalisedText = Template.bind({}) +ExternalisedText.storyName = 'Externalised text' +ExternalisedText.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: externalisedTextUrl, + }, +} satisfies AttachmentProps + +export const Externalised404 = Template.bind({}) +Externalised404.storyName = 'Externalised 404' +Externalised404.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: '/this-leads-nowhere.json', + }, +} satisfies AttachmentProps + +export const ExternalisedCors = Template.bind({}) +ExternalisedCors.storyName = 'Externalised CORS error' +ExternalisedCors.args = { + attachment: { + mediaType: 'application/json', + contentEncoding: AttachmentContentEncoding.IDENTITY, + body: '', + url: 'https://cucumber.io', + }, +} satisfies AttachmentProps diff --git a/src/components/gherkin/attachment/Attachment.tsx b/src/components/gherkin/attachment/Attachment.tsx new file mode 100644 index 00000000..e7fe653f --- /dev/null +++ b/src/components/gherkin/attachment/Attachment.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { ErrorBoundary } from 'react-error-boundary' + +import { + AttachmentClasses, + AttachmentProps, + DefaultComponent, + useCustomRendering, +} from '../../customise/index.js' +import { ErrorMessage } from '../ErrorMessage.js' +import defaultStyles from './Attachment.module.scss' +import { Image } from './Image.js' +import { Log } from './Log.js' +import { Text } from './Text.js' +import { Unknown } from './Unknown.js' +import { Video } from './Video.js' + +const DefaultRenderer: DefaultComponent = ({ + attachment, + styles, +}) => { + if (attachment.mediaType.match(/^image\//)) { + return + } else if (attachment.mediaType.match(/^video\//)) { + return