diff --git a/.changeset/modern-laws-type.md b/.changeset/modern-laws-type.md new file mode 100644 index 0000000..3501e2f --- /dev/null +++ b/.changeset/modern-laws-type.md @@ -0,0 +1,5 @@ +--- +'@envyjs/webui': minor +--- + +Added ability to copy request details as a curl command diff --git a/packages/webui/package.json b/packages/webui/package.json index 452e308..35d490c 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -43,9 +43,11 @@ "@envyjs/client": "0.2.2", "@envyjs/core": "0.3.2", "chalk": "^4.1.2", + "curl-generator": "^0.3.1", "dayjs": "^1.11.10", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "react-icons": "^4.11.0", "react-json-view": "^1.21.3", "serve-handler": "^6.1.5", diff --git a/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx b/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx new file mode 100644 index 0000000..4281e28 --- /dev/null +++ b/packages/webui/src/components/ui/CopyAsCurlButton.test.tsx @@ -0,0 +1,140 @@ +import { act, cleanup, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { toast } from 'react-hot-toast'; + +import { Trace } from '@/types'; + +import CopyAsCurlButton from './CopyAsCurlButton'; + +jest.mock('react-hot-toast'); + +describe('CopyAsCurlButton', () => { + const originalClipboard = { ...global.navigator.clipboard }; + + let trace: Trace; + let writeTextFn: jest.Mock; + let toastFn: jest.Mock; + + beforeEach(() => { + trace = { + http: { + method: 'POST', + url: 'https://www.foo.com/bar/baz', + }, + } as any; + + writeTextFn = jest.fn(); + Object.assign(window.navigator, { + clipboard: { writeText: writeTextFn }, + }); + + toastFn = jest.fn(); + jest.mocked(toast.success).mockImplementation(toastFn); + }); + + afterEach(() => { + jest.resetAllMocks(); + + Object.assign(window.navigator, { + clipboard: originalClipboard, + }); + + cleanup(); + }); + + it('should render without error', () => { + render(); + }); + + it('should render a button with the expected label', () => { + const { getByRole } = render(); + + const button = getByRole('button'); + expect(button).toHaveTextContent('Copy as cURL snippet'); + }); + + describe('cURL snippet', () => { + it('should copy cURL snippet to clipboard when clicked', async () => { + const { getByRole } = render(); + + await act(async () => { + const button = getByRole('button'); + expect(button).toHaveTextContent('Copy as cURL snippet'); + + await userEvent.click(button); + }); + + const expectedCurl = `curl https://www.foo.com/bar/baz \\ + -X POST`; + + expect(writeTextFn).toHaveBeenCalledWith(expectedCurl); + }); + + it('should include headers in snippet if headers are present', async () => { + const traceWithHeaders = { + http: { + ...trace.http, + requestHeaders: { + foo: 'bar', + baz: 'qux', + }, + }, + } as any; + + const { getByRole } = render(); + + await act(async () => { + const button = getByRole('button'); + expect(button).toHaveTextContent('Copy as cURL snippet'); + + await userEvent.click(button); + }); + + const expectedCurl = `curl https://www.foo.com/bar/baz \\ + -X POST \\ +-H "foo: bar" \\ +-H "baz: qux"`; + + expect(writeTextFn).toHaveBeenCalledWith(expectedCurl); + }); + + it('should include body in snippet if body is present', async () => { + const traceWithHeaders = { + http: { + ...trace.http, + requestBody: JSON.stringify({ foo: 'bar', baz: 'qux' }), + }, + } as any; + + const { getByRole } = render(); + + await act(async () => { + const button = getByRole('button'); + expect(button).toHaveTextContent('Copy as cURL snippet'); + + await userEvent.click(button); + }); + + const expectedCurl = `curl https://www.foo.com/bar/baz \\ + -X POST \\ +-d "{\\"foo\\":\\"bar\\",\\"baz\\":\\"qux\\"}"`; + + expect(writeTextFn).toHaveBeenCalledWith(expectedCurl); + }); + }); + + it('should display toast notification when copied', async () => { + const { getByRole } = render(); + + await act(async () => { + const button = getByRole('button'); + expect(button).toHaveTextContent('Copy as cURL snippet'); + + await userEvent.click(button); + }); + + expect(toastFn).toHaveBeenCalledWith('cURL snippet written to clipboard', { + position: 'top-right', + }); + }); +}); diff --git a/packages/webui/src/components/ui/CopyAsCurlButton.tsx b/packages/webui/src/components/ui/CopyAsCurlButton.tsx new file mode 100644 index 0000000..c1895a3 --- /dev/null +++ b/packages/webui/src/components/ui/CopyAsCurlButton.tsx @@ -0,0 +1,37 @@ +import { CurlGenerator } from 'curl-generator'; +import { toast } from 'react-hot-toast'; +import { HiOutlineClipboardCopy } from 'react-icons/hi'; + +import { IconButton } from '@/components'; +import { Trace } from '@/types'; +import { cloneHeaders, flatMapHeaders, safeParseJson } from '@/utils'; + +type CopyAsCurlButtonProps = { + trace: Trace; +}; + +export default function CopyAsCurlButton({ trace, ...props }: CopyAsCurlButtonProps) { + async function copyAsCurl() { + const headers = flatMapHeaders(cloneHeaders(trace.http!.requestHeaders, true)); + const body = safeParseJson(trace.http!.requestBody) ?? null; + + const curlSnippet = CurlGenerator({ + method: trace.http!.method as any, + url: trace.http!.url, + headers, + body, + }); + + await navigator.clipboard.writeText(curlSnippet); + + toast.success('cURL snippet written to clipboard', { + position: 'top-right', + }); + } + + return ( + await copyAsCurl()}> + Copy as cURL snippet + + ); +} diff --git a/packages/webui/src/components/ui/MainDisplay.test.tsx b/packages/webui/src/components/ui/MainDisplay.test.tsx index ea83143..569eeeb 100644 --- a/packages/webui/src/components/ui/MainDisplay.test.tsx +++ b/packages/webui/src/components/ui/MainDisplay.test.tsx @@ -4,6 +4,11 @@ import { setUseApplicationData } from '@/testing/mockUseApplication'; import MainDisplay from './MainDisplay'; +jest.mock('react-hot-toast', () => ({ + Toaster: function MockToaster() { + return
Mock Toaster component
; + }, +})); jest.mock( '@/components/ui/TraceDetail', () => @@ -52,4 +57,14 @@ describe('MainDisplay', () => { const connectionStatus = queryByTestId('mock-trace-detail'); expect(connectionStatus).toBeVisible(); }); + + it('should render Toaster component ', () => { + setUseApplicationData({ selectedTraceId: '1' }); + + const { queryByTestId } = render(); + + const toaster = queryByTestId('mock-toaster'); + expect(toaster).toBeVisible(); + expect(toaster).toHaveTextContent('Mock Toaster component'); + }); }); diff --git a/packages/webui/src/components/ui/MainDisplay.tsx b/packages/webui/src/components/ui/MainDisplay.tsx index 1bb0785..97277f9 100644 --- a/packages/webui/src/components/ui/MainDisplay.tsx +++ b/packages/webui/src/components/ui/MainDisplay.tsx @@ -1,3 +1,5 @@ +import { Toaster } from 'react-hot-toast'; + import TraceDetail from '@/components/ui/TraceDetail'; import TraceList from '@/components/ui/TraceList'; import useApplication from '@/hooks/useApplication'; @@ -9,6 +11,7 @@ export default function MainDisplay() {
{traceId && } +
); } diff --git a/packages/webui/src/components/ui/TraceDetail.test.tsx b/packages/webui/src/components/ui/TraceDetail.test.tsx index 04c5734..37c85d3 100644 --- a/packages/webui/src/components/ui/TraceDetail.test.tsx +++ b/packages/webui/src/components/ui/TraceDetail.test.tsx @@ -41,6 +41,13 @@ jest.mock('@/components', () => ({ }, })); +jest.mock( + '@/components/ui/CopyAsCurlButton', + () => + function CopyAsCurlButton({ trace, ...props }: any) { + return
Mock CopyAsCurlButton component: {trace.id}
; + }, +); jest.mock( '@/components/ui/QueryParams', () => @@ -218,6 +225,30 @@ describe('TraceDetail', () => { expect(service).toHaveTextContent('Sent from my-service'); }); + it('should display button to copy as cURL snippet', () => { + getSelectedTraceFn.mockReturnValue({ + ...mockTrace, + serviceName: 'my-service', + http: { + ...mockTrace.http, + method: 'PATCH', + statusCode: 204, + statusMessage: 'No Content', + responseHeaders: {}, + responseBody: '', + duration: 1234, + }, + }); + + const { getByTestId } = render(); + + const summary = getByTestId('summary'); + const copyAsCurl = within(summary).getByTestId('copy-as-curl'); + + expect(copyAsCurl).toBeVisible(); + expect(copyAsCurl).toHaveTextContent('Mock CopyAsCurlButton component: 1'); + }); + it.each([ { statusCode: 500, color: 'purple-500' }, { statusCode: 400, color: 'red-500' }, diff --git a/packages/webui/src/components/ui/TraceDetail.tsx b/packages/webui/src/components/ui/TraceDetail.tsx index 8442a18..ce6367e 100644 --- a/packages/webui/src/components/ui/TraceDetail.tsx +++ b/packages/webui/src/components/ui/TraceDetail.tsx @@ -11,13 +11,14 @@ import { } from '@/systems'; import { getHeader, numberFormat, pathAndQuery } from '@/utils'; +import CopyAsCurlButton from './CopyAsCurlButton'; import QueryParams from './QueryParams'; import RequestHeaders from './RequestHeaders'; import ResponseHeaders from './ResponseHeaders'; import TimingsDiagram from './TimingsDiagram'; type CodeDisplayProps = { - contentType: string | null; + contentType: string | string[] | null; children: any; }; type DetailProps = React.HTMLAttributes; @@ -25,8 +26,8 @@ type DetailProps = React.HTMLAttributes; function CodeDisplay({ contentType, children, ...props }: CodeDisplayProps) { if (!children) return null; - const isJson = - contentType?.includes('application/json') || contentType?.includes('application/graphql-response+json'); + const type = Array.isArray(contentType) ? contentType[0] : contentType; + const isJson = type?.includes('application/json') || contentType?.includes('application/graphql-response+json'); const isXml = contentType?.includes('application/xml'); return ( @@ -122,8 +123,11 @@ export default function TraceDetail({ className }: DetailProps) { {url} -
- Sent from {serviceName} +
+
+ Sent from {serviceName} +
+
diff --git a/packages/webui/src/utils/utils.test.ts b/packages/webui/src/utils/utils.test.ts index 71036b7..7c414c6 100644 --- a/packages/webui/src/utils/utils.test.ts +++ b/packages/webui/src/utils/utils.test.ts @@ -2,7 +2,16 @@ import { twMerge } from 'tailwind-merge'; import { Trace } from '@/types'; -import { cloneHeaders, getHeader, numberFormat, pathAndQuery, prettyFormat, safeParseJson, tw } from './utils'; +import { + cloneHeaders, + flatMapHeaders, + getHeader, + numberFormat, + pathAndQuery, + prettyFormat, + safeParseJson, + tw, +} from './utils'; jest.mock('tailwind-merge'); @@ -180,6 +189,54 @@ describe('utils', () => { }); }); + describe('flatMapHeaders', () => { + it('should return an empty object if no headers supplied', () => { + const result = flatMapHeaders(undefined); + + expect(result).toEqual({}); + }); + + it('should return identical headers in a new object', () => { + const originalHeaders = { + foo: 'bar', + baz: 'qux', + }; + + const result = flatMapHeaders(originalHeaders); + + expect(result).toEqual(originalHeaders); + expect(result).not.toBe(originalHeaders); + }); + + it('should transform header array values to comma delimited strings', () => { + const originalHeaders = { + foo: ['bar', 'baz'], + bar: ['baz', 'qux'], + }; + + const result = flatMapHeaders(originalHeaders); + + expect(result).toEqual({ + foo: 'bar,baz', + bar: 'baz,qux', + }); + }); + + it('should retain header string values', () => { + const originalHeaders = { + foo: 'bar', + baz: 'qux', + }; + + const result = flatMapHeaders(originalHeaders); + + expect(result).toEqual({ + foo: 'bar', + baz: 'qux', + }); + }); + }); + describe('getHeader', () => { const headers = { Foo: 'BAR', @@ -209,10 +266,10 @@ describe('utils', () => { expect(result).toEqual('BAR'); }); - it('should return undefined if header not found', () => { + it('should return null if header not found', () => { const result = getHeader(headers, 'banana'); - expect(result).toBeUndefined(); + expect(result).toBeNull(); }); }); diff --git a/packages/webui/src/utils/utils.ts b/packages/webui/src/utils/utils.ts index 5862ac0..7414e7e 100644 --- a/packages/webui/src/utils/utils.ts +++ b/packages/webui/src/utils/utils.ts @@ -3,7 +3,8 @@ import { twMerge } from 'tailwind-merge'; import { Trace } from '@/types'; -type Headers = HttpRequest['requestHeaders'] | HttpRequest['responseHeaders'] | undefined; +type Headers = HttpRequest['requestHeaders']; +type HeadersOrUndefined = Headers | undefined; export function pathAndQuery(trace: Trace, decodeQs = false): [string, string] { const [path, qs] = (trace.http?.path ?? '').split('?'); @@ -14,21 +15,32 @@ export function numberFormat(num: number): string { return Intl.NumberFormat('en-US').format(num); } -export function cloneHeaders(headers: Headers, lowercase = true): Record { +export function cloneHeaders(headers: HeadersOrUndefined, lowercase = true): Headers { if (!headers) return {}; - return Object.entries(headers).reduce>((a, [k, v]) => { + return Object.entries(headers).reduce((a, [k, v]) => { const newKey = lowercase ? k.toLowerCase() : k; a[newKey] = v; return a; }, {}); } -export function getHeader(headers: Headers, name: string): string | null { +export function flatMapHeaders(headers: HeadersOrUndefined): Record { + if (!headers) return {}; + + return Object.entries(headers).reduce>((a, [k, v]) => { + if (!!v) { + a[k] = Array.isArray(v) ? v.join(',') : v; + } + return a; + }, {}); +} + +export function getHeader(headers: HeadersOrUndefined, name: string): string | string[] | null { if (!headers) return null; const allLowercaseHeaders = cloneHeaders(headers, true); - return allLowercaseHeaders[name.toLowerCase()]; + return allLowercaseHeaders[name.toLowerCase()] ?? null; } export function safeParseJson(data: string | null | undefined): T | null { diff --git a/yarn.lock b/yarn.lock index 64cd53b..de9fb1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4120,6 +4120,13 @@ csv@^5.5.3: csv-stringify "^5.6.5" stream-transform "^2.1.3" +curl-generator@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/curl-generator/-/curl-generator-0.3.1.tgz#b426276261207be495ae3b91a31a64bf5b13cf21" + integrity sha512-AAbB3mMkn2l2b0gzLQVNRenJtxvq7iaEuCi5pXMelpGpUjy3Fpy8zQG/fbbUHK3Zl80bjlroVemlj5dkuvTEqg== + dependencies: + ms "^2.0.0" + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -5537,6 +5544,11 @@ globby@^11.0.0, globby@^11.1.0: merge2 "^1.4.1" slash "^3.0.0" +goober@^2.1.10: + version "2.1.13" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" + integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -7229,7 +7241,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@2.1.3, ms@^2.0.0, ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -8112,6 +8124,13 @@ react-error-overlay@6.0.9: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-icons@^4.11.0: version "4.11.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.11.0.tgz#4b0e31c9bfc919608095cc429c4f1846f4d66c65"