Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added button to allow copying trace as cURL snippet #90

Merged
merged 5 commits into from
Oct 2, 2023
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 .changeset/modern-laws-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envyjs/webui': minor
---

Added ability to copy request details as a curl command
2 changes: 2 additions & 0 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions packages/webui/src/components/ui/CopyAsCurlButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CopyAsCurlButton trace={trace} />);
});

it('should render a button with the expected label', () => {
const { getByRole } = render(<CopyAsCurlButton trace={trace} />);

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(<CopyAsCurlButton trace={trace} />);

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(<CopyAsCurlButton trace={traceWithHeaders} />);

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(<CopyAsCurlButton trace={traceWithHeaders} />);

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(<CopyAsCurlButton trace={trace} />);

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',
});
});
});
37 changes: 37 additions & 0 deletions packages/webui/src/components/ui/CopyAsCurlButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<IconButton {...props} type="standard" Icon={HiOutlineClipboardCopy} onClick={async () => await copyAsCurl()}>
Copy as cURL snippet
</IconButton>
);
}
15 changes: 15 additions & 0 deletions packages/webui/src/components/ui/MainDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { setUseApplicationData } from '@/testing/mockUseApplication';

import MainDisplay from './MainDisplay';

jest.mock('react-hot-toast', () => ({
Toaster: function MockToaster() {
return <div data-test-id="mock-toaster">Mock Toaster component</div>;
},
}));
jest.mock(
'@/components/ui/TraceDetail',
() =>
Expand Down Expand Up @@ -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(<MainDisplay />);

const toaster = queryByTestId('mock-toaster');
expect(toaster).toBeVisible();
expect(toaster).toHaveTextContent('Mock Toaster component');
});
});
3 changes: 3 additions & 0 deletions packages/webui/src/components/ui/MainDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +11,7 @@ export default function MainDisplay() {
<div className="h-full flex flex-col md:flex-row bg-slate-400 overflow-hidden">
<TraceList className="flex-[2]" />
{traceId && <TraceDetail className="flex-[3]" />}
<Toaster />
</div>
);
}
31 changes: 31 additions & 0 deletions packages/webui/src/components/ui/TraceDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ jest.mock('@/components', () => ({
},
}));

jest.mock(
'@/components/ui/CopyAsCurlButton',
() =>
function CopyAsCurlButton({ trace, ...props }: any) {
return <div {...props}>Mock CopyAsCurlButton component: {trace.id}</div>;
},
);
jest.mock(
'@/components/ui/QueryParams',
() =>
Expand Down Expand Up @@ -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(<TraceDetail />);

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' },
Expand Down
14 changes: 9 additions & 5 deletions packages/webui/src/components/ui/TraceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,23 @@ 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<HTMLElement>;

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 (
Expand Down Expand Up @@ -122,8 +123,11 @@ export default function TraceDetail({ className }: DetailProps) {
{url}
</span>
</div>
<div data-test-id="service" className="mt-4">
Sent from <span className="font-bold">{serviceName}</span>
<div className="flex flex-row flex-wrap justify-between items-center mt-4">
<div data-test-id="service">
Sent from <span className="font-bold">{serviceName}</span>
</div>
<CopyAsCurlButton data-test-id="copy-as-curl" trace={trace} />
</div>
</div>
</div>
Expand Down
Loading