├── .nvmrc ├── .github ├── FUNDING.yml ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── test.yml ├── public ├── highlight.js │ └── styles │ │ └── atom-one-dark.css ├── icon.png ├── devtools │ ├── devtools.html │ └── devtools.js ├── background.js ├── manifest.json ├── css │ ├── atom-one-dark.css │ └── atom-one-light.css ├── contentScript_export.js └── index.html ├── jest.setup.ts ├── src ├── helpers │ ├── debounce.ts │ ├── decodeQueryParam.ts │ ├── byteSize.ts │ ├── nsToMs.ts │ ├── getStatusColor.ts │ ├── byteSize.test.ts │ ├── safeJson.ts │ ├── compareHeaders.ts │ ├── jwt.ts │ ├── jwt.test.ts │ ├── getSearchContent.ts │ ├── searchString.ts │ ├── gzip.ts │ ├── searchString.test.ts │ ├── curlHelpers.ts │ └── graphqlHelpers.test.ts ├── react-app-env.d.ts ├── components │ ├── CodeView │ │ ├── index.ts │ │ ├── CodeView.module.css │ │ ├── JsonView.tsx │ │ └── CodeView.tsx │ ├── Layout │ │ ├── index.tsx │ │ ├── HeadBodyLayout.tsx │ │ └── SplitPaneLayout.tsx │ ├── TracingVisualization │ │ ├── index.tsx │ │ ├── useTracingVirtualization.ts │ │ ├── TracingVisualization.tsx │ │ └── TracingVisualizationRow.tsx │ ├── VersionNumber │ │ └── index.tsx │ ├── ShareButton │ │ └── index.tsx │ ├── Badge │ │ └── index.tsx │ ├── Spinner │ │ └── index.tsx │ ├── Bar │ │ └── index.tsx │ ├── Icons │ │ ├── CaretIcon.tsx │ │ ├── CloseIcon.tsx │ │ ├── ChevronIcon.tsx │ │ ├── SearchIcon.tsx │ │ ├── BinIcon.tsx │ │ ├── CodeIcon.tsx │ │ ├── DocsIcon.tsx │ │ ├── MockIcon.tsx │ │ └── LearnIcon.tsx │ ├── Dot │ │ └── index.tsx │ ├── Header │ │ └── index.tsx │ ├── CloseButton │ │ └── index.tsx │ ├── HighlightedText │ │ └── index.tsx │ ├── Textfield │ │ └── index.tsx │ ├── CopyButton │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── CopyAsCurlButton │ │ ├── index.tsx │ │ └── index.test.tsx │ ├── AutoFormatToggleButton │ │ └── index.tsx │ ├── DelayedLoader │ │ └── index.tsx │ ├── Button │ │ ├── index.test.tsx │ │ └── index.tsx │ ├── Popover │ │ └── index.tsx │ ├── SearchInput │ │ └── index.tsx │ ├── Checkbox │ │ └── index.tsx │ ├── Tabs │ │ ├── Tabs.test.tsx │ │ └── index.tsx │ ├── Table │ │ ├── Table.test.tsx │ │ └── index.tsx │ ├── ErrorBoundary │ │ └── index.tsx │ ├── OverflowPopover │ │ └── index.tsx │ └── LocalSearchInput │ │ └── index.tsx ├── hooks │ ├── useToggle.ts │ ├── useVirtualization.ts │ ├── usePrevious.ts │ ├── useHighlight │ │ ├── worker.ts │ │ └── index.ts │ ├── useBytes.ts │ ├── useOperatingSystem.ts │ ├── useDebouncedEffect.ts │ ├── useDebounce.ts │ ├── useKeyDown.ts │ ├── useCopy.ts │ ├── useApolloTracing.ts │ ├── useFormattedCode.ts │ ├── useCopyCurl │ │ ├── useCopyCurl.ts │ │ └── useCopyCurl.test.ts │ ├── useTheme.ts │ ├── useDelay.ts │ ├── useNetworkTabs.tsx │ ├── useBoundingRect.ts │ ├── useUserSettings.ts │ ├── useInterval.ts │ ├── useLatestState.ts │ ├── useOperationFilters.tsx │ ├── useRequestViewSections.tsx │ ├── useSearchStart.ts │ ├── useSearch.tsx │ ├── useFormattedCode.test.tsx │ ├── useMark.test.tsx │ ├── useUserSettings.test.ts │ ├── useMaintainScrollBottom.ts │ ├── useShareMessage.tsx │ └── useMark.ts ├── services │ ├── chromeProvider.ts │ ├── userSettingsService.ts │ ├── searchService.ts │ └── networkMonitor.ts ├── index.tsx ├── config.ts ├── theme.ts ├── containers │ ├── NetworkPanel │ │ ├── PanelSection.tsx │ │ ├── HeaderView │ │ │ ├── parseAuthHeader.ts │ │ │ ├── index.tsx │ │ │ ├── parseAuthHeader.test.ts │ │ │ └── HeaderList.tsx │ │ ├── NetworkDetails │ │ │ ├── TracingView.tsx │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ ├── ResponseRawView.tsx │ │ │ └── ResponseView.tsx │ │ ├── WebSocketNetworkDetails │ │ │ ├── index.tsx │ │ │ └── MessageView.tsx │ │ ├── index.test.tsx │ │ ├── QuickFiltersContainer │ │ │ └── index.tsx │ │ └── NetworkTable │ │ │ └── index.test.tsx │ ├── App │ │ ├── index.tsx │ │ └── Search.test.tsx │ ├── SearchPanel │ │ ├── index.tsx │ │ └── SearchResults.tsx │ └── Main │ │ └── index.tsx ├── setupTests.ts ├── test-utils.tsx ├── types.ts ├── index.css └── mocks │ └── mock-chrome.ts ├── jest.config.js ├── docs ├── main.jpg └── compare.jpg ├── tsconfig.base.json ├── prettier.config.js ├── TODO ├── .vscode └── settings.json ├── scripts ├── set-manifest-version.js ├── set-chrome-settings.js ├── set-firefox-settings.js └── set-manifest-content-script.js ├── .gitignore ├── .eslintrc.json ├── PRIVACY.md ├── tsconfig.json ├── tailwind.config.js ├── LICENCE ├── craco.config.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: warrenday 2 | -------------------------------------------------------------------------------- /public/highlight.js/styles/atom-one-dark.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom" 2 | -------------------------------------------------------------------------------- /src/helpers/debounce.ts: -------------------------------------------------------------------------------- 1 | export { debounce } from "ts-debounce" 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ["./jest.setup.ts"], 3 | } 4 | -------------------------------------------------------------------------------- /docs/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrenday/graphql-network-inspector/HEAD/docs/main.jpg -------------------------------------------------------------------------------- /docs/compare.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrenday/graphql-network-inspector/HEAD/docs/compare.jpg -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/warrenday/graphql-network-inspector/HEAD/public/icon.png -------------------------------------------------------------------------------- /src/components/CodeView/index.ts: -------------------------------------------------------------------------------- 1 | export { CodeView } from "./CodeView" 2 | export { JsonView } from "./JsonView" 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: false, 5 | singleQuote: true, 6 | } 7 | -------------------------------------------------------------------------------- /public/devtools/devtools.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | export { HeadBodyLayout } from "./HeadBodyLayout" 2 | export { SplitPaneLayout } from "./SplitPaneLayout" 3 | -------------------------------------------------------------------------------- /public/devtools/devtools.js: -------------------------------------------------------------------------------- 1 | chrome.devtools.panels.create( 2 | "GraphQL Network", 3 | "/icon.png", 4 | "/index.html", 5 | null 6 | ) 7 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Nice to have 2 | 3 | - Allow sorting of messages by time ascending/descending 4 | - Allow clearing of websocket messages 5 | - Show collapsed view of messages 6 | -------------------------------------------------------------------------------- /src/hooks/useToggle.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from "react" 2 | 3 | export const useToggle = () => { 4 | return useReducer((prev, value = !prev) => value, true) 5 | } 6 | -------------------------------------------------------------------------------- /src/hooks/useVirtualization.ts: -------------------------------------------------------------------------------- 1 | import { useVirtual, VirtualItem } from 'react-virtual' 2 | 3 | export type { VirtualItem } 4 | 5 | export const useVirtualization = useVirtual; 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.tabSize": 2, 4 | "eslint.enable": true, 5 | "eslint.validate": ["javascript", "typescript", "typescriptreact"] 6 | } 7 | -------------------------------------------------------------------------------- /public/background.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onInstalled.addListener(function (details) { 2 | if (details.reason === "install") { 3 | chrome.tabs.create({ url: "https://www.overstacked.io/?install=true" }) 4 | } 5 | }) 6 | -------------------------------------------------------------------------------- /src/components/TracingVisualization/index.tsx: -------------------------------------------------------------------------------- 1 | export { TracingVisualization } from "./TracingVisualization" 2 | export { TracingVisualizationRow } from "./TracingVisualizationRow" 3 | export { useTracingVirtualization } from "./useTracingVirtualization" 4 | -------------------------------------------------------------------------------- /src/services/chromeProvider.ts: -------------------------------------------------------------------------------- 1 | import { mockChrome } from "../mocks/mock-chrome" 2 | 3 | export const chromeProvider = (): typeof chrome => { 4 | return typeof chrome === "undefined" || !chrome?.devtools 5 | ? mockChrome 6 | : chrome 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/decodeQueryParam.ts: -------------------------------------------------------------------------------- 1 | const decodeQueryParam = (param: string): string => { 2 | try { 3 | return decodeURIComponent(param.replace(/\+/g, ' ')) 4 | } catch (e) { 5 | return param 6 | } 7 | } 8 | 9 | export default decodeQueryParam 10 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react" 2 | 3 | export const usePrevious = (value: T | null) => { 4 | const ref = useRef(null) 5 | useEffect(() => { 6 | ref.current = value 7 | }, [value]) 8 | return ref.current 9 | } 10 | -------------------------------------------------------------------------------- /src/components/VersionNumber/index.tsx: -------------------------------------------------------------------------------- 1 | const VersionNumber = () => { 2 | return ( 3 |
4 | v{process.env.REACT_APP_VERSION || '0.0.0'} 5 |
6 | ) 7 | } 8 | 9 | export default VersionNumber 10 | -------------------------------------------------------------------------------- /src/hooks/useHighlight/worker.ts: -------------------------------------------------------------------------------- 1 | import hljs from "highlight.js" 2 | import { MessagePayload } from "./" 3 | 4 | onmessage = (event) => { 5 | const { language, code } = event.data as MessagePayload 6 | const result = hljs.highlight(code, { language }) 7 | postMessage(result.value) 8 | } 9 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react" 2 | import ReactDOM from "react-dom" 3 | import { App } from "./containers/App" 4 | import "./index.css" 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ) 12 | -------------------------------------------------------------------------------- /src/helpers/byteSize.ts: -------------------------------------------------------------------------------- 1 | export type Unit = "mb" 2 | 3 | const conversion: Record = { 4 | mb: 1000000, 5 | } 6 | 7 | export const byteSize = (bytes: number, options?: { unit: Unit }): number => { 8 | return Number((bytes / conversion[options?.unit || "mb"]).toFixed(2)) 9 | } 10 | -------------------------------------------------------------------------------- /src/components/CodeView/CodeView.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 1.2rem; 3 | line-height: 2.1rem; 4 | font-family: Monaco, Menlo, Consolas, "Droid Sans Mono", "Inconsolata", 5 | "Courier New", monospace; 6 | } 7 | 8 | .container span { 9 | background: none !important; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ShareButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../Button" 2 | 3 | interface IShareButtonProps { 4 | onClick: () => void 5 | } 6 | 7 | export const ShareButton = (props: IShareButtonProps) => { 8 | const { onClick } = props 9 | 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useBytes.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { byteSize, Unit } from "../helpers/byteSize" 3 | 4 | export const useByteSize = ( 5 | bytes: number, 6 | options?: { unit: Unit } 7 | ): number => { 8 | return useMemo(() => { 9 | return byteSize(bytes, options) 10 | }, [bytes, options]) 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/nsToMs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts nanoseconds to milliseconds. 3 | * 4 | * Significant to the hundredth place. 5 | * 6 | * This function does NOT account for Roundoff Errors. 7 | * 8 | * ``` 9 | * nsToMs(123456789) 10 | * // 123.46 11 | * ``` 12 | */ 13 | export const nsToMs = (ns: number) => Math.round(ns / 10000) / 100 14 | -------------------------------------------------------------------------------- /src/components/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | 3 | interface IBadgeProps {} 4 | 5 | export const Badge: FC = (props) => { 6 | const { children } = props 7 | 8 | return ( 9 | 10 | {children} 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Provide a brief overview of what this feature/change contains to help reviewers. 4 | 5 | ## Screenshot 6 | 7 | Provide a screenshot or gif of the new feature in both dark and light mode. 8 | 9 | ## Checklist 10 | 11 | - [ ] Displays correctly with both dark and light mode (see useTheme.ts) 12 | - [ ] Unit/Integration tests added 13 | -------------------------------------------------------------------------------- /src/helpers/getStatusColor.ts: -------------------------------------------------------------------------------- 1 | const colors: Record = { 2 | '-1': '#6B7280', 3 | '0': '#DC2626', 4 | '101': '#10B981', 5 | '200': '#10B981', 6 | '300': '#FBBF24', 7 | '400': '#DC2626', 8 | '500': '#DC2626', 9 | } 10 | 11 | export const getStatusColor = (status: number = 0): string => { 12 | const statusCode = String(status) 13 | return colors[statusCode] || colors['0'] 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/byteSize.test.ts: -------------------------------------------------------------------------------- 1 | import { byteSize } from "./byteSize" 2 | 3 | describe("byteSize", () => { 4 | it("converts bytes to megabytes", () => { 5 | const tests = [ 6 | [100000, 0.1], 7 | [1000000, 1], 8 | [10000000, 10], 9 | [15500000, 15.5], 10 | ] 11 | 12 | tests.forEach(([input, output]) => { 13 | expect(byteSize(input)).toBe(output) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/hooks/useOperatingSystem.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import { chromeProvider } from "../services/chromeProvider" 3 | 4 | export const useOperatingSystem = () => { 5 | const [os, setOs] = useState("") 6 | 7 | useEffect(() => { 8 | const chrome = chromeProvider() 9 | chrome.runtime.getPlatformInfo((info) => { 10 | setOs(info.os) 11 | }) 12 | }, [setOs]) 13 | 14 | return os 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Spinner/index.tsx: -------------------------------------------------------------------------------- 1 | interface ISpinnerProps {} 2 | 3 | export const Spinner = (props: ISpinnerProps) => { 4 | return ( 5 |
6 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | export const useDebouncedEffect = ( 4 | cb: () => void, 5 | deps: any[], 6 | delay = 300 7 | ) => { 8 | useEffect(() => { 9 | const timeout = setTimeout(() => { 10 | cb() 11 | }, delay) 12 | 13 | return () => { 14 | clearTimeout(timeout) 15 | } 16 | // eslint-disable-next-line react-hooks/exhaustive-deps 17 | }, [cb, delay, ...deps]) 18 | } 19 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | // The maximum request response size in megabytes 3 | // that the app can reasonably handle. For responses 4 | // larger than this we can instead show user-friendly 5 | // messages to avoid freezing/crashing the app. 6 | // 7 | // Examples of where large requests cause issues could be: 8 | // parsing/stringifying json 9 | // rendering formatted code 10 | // rendering tracing views 11 | maxUsableResponseSizeMb: 1.5, 12 | } 13 | -------------------------------------------------------------------------------- /scripts/set-manifest-version.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const packageJson = require("../package.json") 3 | const manifestPath = "./build/manifest.json" 4 | 5 | const run = async () => { 6 | const json = await fs.promises.readFile(manifestPath, "utf-8") 7 | const manifest = JSON.parse(json) 8 | manifest.version = packageJson.version 9 | manifest.name = "GraphQL Network Inspector" 10 | await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) 11 | } 12 | run() 13 | -------------------------------------------------------------------------------- /scripts/set-chrome-settings.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const manifestPath = "./build/manifest.json" 3 | 4 | const run = async () => { 5 | const json = await fs.promises.readFile(manifestPath, "utf-8") 6 | const manifest = JSON.parse(json) 7 | 8 | // Remove background scripts from the manifest as this is only 9 | // supported in Firefox. 10 | delete manifest.background.scripts 11 | 12 | await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) 13 | } 14 | run() 15 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timeout = setTimeout(() => { 8 | setDebouncedValue(value) 9 | }, delay) 10 | 11 | return () => { 12 | clearTimeout(timeout) 13 | } 14 | }, [value, delay]) 15 | 16 | return debouncedValue 17 | } 18 | 19 | export default useDebounce 20 | -------------------------------------------------------------------------------- /src/components/Layout/HeadBodyLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | interface IHeadBodyLayoutProps { 4 | header: ReactNode 5 | body: ReactNode 6 | } 7 | 8 | export const HeadBodyLayout = (props: IHeadBodyLayoutProps) => { 9 | const { header, body } = props 10 | return ( 11 |
12 |
{header}
13 |
{body}
14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # development 12 | /dist 13 | 14 | # production 15 | /build 16 | /build-chrome.zip 17 | /build-firefox.zip 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | .idea/ 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | .env.* -------------------------------------------------------------------------------- /src/components/Bar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface IBarProps { 4 | children: React.ReactNode 5 | className?: string 6 | testId?: string 7 | } 8 | 9 | export const Bar = (props: IBarProps) => { 10 | const { children, className, testId } = props 11 | 12 | return ( 13 |
17 | {children} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers/safeJson.ts: -------------------------------------------------------------------------------- 1 | export const stringify = ( 2 | value: any, 3 | replacer?: () => any, 4 | space?: string | number 5 | ): string => { 6 | if (!value) { 7 | return "" 8 | } 9 | try { 10 | return JSON.stringify(value, replacer, space) 11 | } catch (e) { 12 | return "{}" 13 | } 14 | } 15 | 16 | export const parse = ( 17 | text?: string, 18 | reviver?: () => any 19 | ): T | null => { 20 | try { 21 | return JSON.parse(text as string, reviver) 22 | } catch (e) { 23 | return null 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useKeyDown.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | type Code = "ArrowDown" | "ArrowUp" | "Enter" 4 | 5 | export const useKeyDown = (code: Code, cb: (e: KeyboardEvent) => void) => { 6 | useEffect(() => { 7 | const handleKeyPress = (event: KeyboardEvent) => { 8 | if (event.code === code) { 9 | cb(event) 10 | } 11 | } 12 | window.addEventListener("keydown", handleKeyPress) 13 | 14 | return () => { 15 | window.removeEventListener("keydown", handleKeyPress) 16 | } 17 | }, [code, cb]) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Icons/CaretIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface ICaretIconProps extends SVGAttributes<{}> {} 4 | 5 | export const CaretIcon = (props: ICaretIconProps) => { 6 | return ( 7 | 8 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Dot/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | 3 | interface IDotProps { 4 | title?: string 5 | } 6 | 7 | export const Dot: FC = (props) => { 8 | const { title, children } = props 9 | 10 | return ( 11 |
17 | {children} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useCopy.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import copy from "copy-to-clipboard" 3 | 4 | const useCopy = () => { 5 | const [copied, setCopied] = useState(false) 6 | 7 | useEffect(() => { 8 | if (copied) { 9 | const timeout = setTimeout(() => setCopied(false), 1000) 10 | 11 | return () => { 12 | clearTimeout(timeout) 13 | } 14 | } 15 | }, [copied]) 16 | 17 | return { 18 | isCopied: copied, 19 | copy: (text: string) => { 20 | setCopied(true) 21 | copy(text) 22 | }, 23 | } 24 | } 25 | 26 | export default useCopy 27 | -------------------------------------------------------------------------------- /src/hooks/useApolloTracing.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import * as safeJson from "@/helpers/safeJson" 3 | import { IApolloServerTracing, IResponseBody, Maybe } from "@/types" 4 | 5 | export const useApolloTracing = (responseBody: Maybe): Maybe => { 6 | const cleanResponseBody = responseBody || ''; 7 | const tracing = useMemo(() => { 8 | const parsedResponse = safeJson.parse(cleanResponseBody) || {} 9 | const tracing = parsedResponse?.extensions?.tracing 10 | return tracing 11 | }, [cleanResponseBody]) 12 | 13 | return tracing 14 | } 15 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { OperationType } from "./helpers/graphqlHelpers" 2 | 3 | const operationColors: Record = { 4 | query: { 5 | text: "text-green-400", 6 | bg: "bg-green-400", 7 | }, 8 | mutation: { 9 | text: "text-indigo-400", 10 | bg: "bg-indigo-400", 11 | }, 12 | subscription: { 13 | text: "text-blue-400", 14 | bg: "bg-blue-400", 15 | }, 16 | persisted: { 17 | text: "text-yellow-400", 18 | bg: "bg-yellow-400", 19 | }, 20 | } 21 | 22 | const theme = { 23 | operationColors, 24 | } 25 | 26 | export default theme 27 | -------------------------------------------------------------------------------- /scripts/set-firefox-settings.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const manifestPath = "./build/manifest.json" 3 | 4 | const run = async () => { 5 | const json = await fs.promises.readFile(manifestPath, "utf-8") 6 | const manifest = JSON.parse(json) 7 | manifest.browser_specific_settings = { 8 | gecko: { 9 | id: "warrenjday@graphqlnetworkinspector.com", 10 | }, 11 | } 12 | 13 | // Remove background scripts from the manifest as this is only 14 | // supported in Chrome. 15 | delete manifest.background.service_worker 16 | 17 | await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) 18 | } 19 | run() 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "webextensions": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2022, 9 | "sourceType": "module" 10 | }, 11 | "parser": "@typescript-eslint/parser", 12 | "plugins": ["@typescript-eslint", "react-hooks"], 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/eslint-recommended" 16 | ], 17 | "rules": { 18 | "no-unused-vars": "off", 19 | "react-hooks/rules-of-hooks": "error", // Enforces the Rules of Hooks 20 | "react-hooks/exhaustive-deps": "error" // Checks effect dependencies 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface ICloseIconProps extends SVGAttributes<{}> {} 4 | 5 | export const CloseIcon = (props: ICloseIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | GraphQL Network Inspector (GNI) does not collect any data of any kind. 2 | 3 | - GNI has no home server. 4 | - GNI doesn't embed any analytic or telemetry hooks in its code. 5 | - GNI doesn't use any third-party services that might collect data. 6 | - The only point data can leave GNI is when you click "Export" to open a new tab to graphdev.app. Exported data is not sent to graphdev.app servers and only exists within your browser. Only by clicking "Save" on graphdev.app does data leave your machine, data stored on graphdev.app is encrypted. 7 | 8 | GitHub is used to host the GNI project. GitHub, Inc. (a subsidiary of Microsoft Corporation) owns GitHub and is unrelated to GNI. 9 | -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from "react" 2 | 3 | interface IHeaderProps { 4 | leftContent?: ReactNode 5 | rightContent?: ReactNode 6 | className?: string 7 | } 8 | 9 | export const Header: FC = (props) => { 10 | const { rightContent, leftContent, children, className } = props 11 | 12 | return ( 13 |
16 | {leftContent && leftContent} 17 | {children} 18 | {rightContent && ( 19 |
{rightContent}
20 | )} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/components/CloseButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import { Button } from "../Button" 3 | import { CloseIcon } from "../Icons/CloseIcon" 4 | 5 | interface ICloseButtonProps { 6 | onClick: () => void 7 | testId?: string 8 | className?: string 9 | } 10 | 11 | export const CloseButton: FC = (props) => { 12 | const { children, onClick, className, testId } = props 13 | return ( 14 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/HighlightedText/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { searchString } from "../../helpers/searchString" 3 | 4 | interface IHighlightedTextProps { 5 | text: string 6 | highlight: string 7 | buffer?: number 8 | } 9 | 10 | export const HighlightedText = (props: IHighlightedTextProps) => { 11 | const { text, highlight, buffer } = props 12 | const { start, match, end } = useMemo( 13 | () => searchString({ text, search: highlight, buffer }), 14 | [text, highlight, buffer] 15 | ) 16 | 17 | return ( 18 | <> 19 | {start} 20 | {match} 21 | {end} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GraphQL Network Inspector (Dev Build)", 3 | "version": "1.0", 4 | "description": "Simple and clean network inspector for GraphQL", 5 | "icons": { 6 | "128": "icon.png" 7 | }, 8 | "manifest_version": 3, 9 | "permissions": ["webRequest", "storage"], 10 | "host_permissions": [""], 11 | "devtools_page": "devtools/devtools.html", 12 | "content_scripts": [ 13 | { 14 | "matches": ["http://localhost:3000/draft?*"], 15 | "js": ["contentScript_export.js"], 16 | "run_at": "document_idle" 17 | } 18 | ], 19 | "background": { 20 | "service_worker": "background.js", 21 | "scripts": ["background.js"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Icons/ChevronIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface IChevronIconProps extends SVGAttributes<{}> {} 4 | 5 | export const ChevronIcon = (props: IChevronIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 18 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/PanelSection.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | 3 | export const Panels: FC = (props) => { 4 | const { children } = props 5 | 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | 13 | interface IPanelSectionProps { 14 | title?: string 15 | className?: string 16 | } 17 | 18 | export const PanelSection: FC = (props) => { 19 | const { title, children, className } = props 20 | 21 | return ( 22 |
23 | {title &&
{title}
} 24 |
{children}
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/compareHeaders.ts: -------------------------------------------------------------------------------- 1 | import type { IHeader } from './networkHelpers' 2 | 3 | /** 4 | * Compare all headers and ensure an exact match 5 | * @param headers 6 | * @param expectedHeaders 7 | */ 8 | const compareHeaders = ( 9 | headers: IHeader[], 10 | expectedHeaders: IHeader[] 11 | ): boolean => { 12 | if (headers.length !== expectedHeaders.length) { 13 | return false 14 | } 15 | 16 | for (let i = 0; i < headers.length; i++) { 17 | if (headers[i].name !== expectedHeaders[i].name) { 18 | return false 19 | } 20 | 21 | if (headers[i].value !== expectedHeaders[i].value) { 22 | return false 23 | } 24 | } 25 | 26 | return true 27 | } 28 | 29 | export default compareHeaders 30 | -------------------------------------------------------------------------------- /src/components/Icons/SearchIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface ISearchIconProps extends SVGAttributes<{}> {} 4 | 5 | export const SearchIcon = (props: ISearchIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 17 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/jwt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Decode a JWT 3 | * @param token the JWT token 4 | * @returns the decoded JWT data 5 | */ 6 | const decode = (token: string) => { 7 | try { 8 | const base64Url = token.split(".")[1] // Get the payload part 9 | const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/") // Convert to base64 10 | const jsonPayload = decodeURIComponent( 11 | atob(base64) 12 | .split("") 13 | .map((c) => { 14 | return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2) 15 | }) 16 | .join("") 17 | ) 18 | 19 | return JSON.parse(jsonPayload) 20 | } catch (error) { 21 | throw new Error("Invalid token") 22 | } 23 | } 24 | 25 | export { decode } 26 | -------------------------------------------------------------------------------- /src/components/Icons/BinIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface IBinIconProps extends SVGAttributes<{}> {} 4 | 5 | export const BinIcon = (props: IBinIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 16 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": "." 23 | }, 24 | "include": [ 25 | "src", 26 | "devtools" 27 | ], 28 | "extends": "./tsconfig.base.json" 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Icons/CodeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface ICodeIconProps extends SVGAttributes<{}> {} 4 | 5 | export const CodeIcon = (props: ICodeIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem", className } = props 7 | 8 | return ( 9 | 19 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Textfield/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | type ITextfieldProps = React.DetailedHTMLProps< 4 | React.InputHTMLAttributes, 5 | HTMLInputElement 6 | > & { 7 | testId?: string 8 | } 9 | 10 | const className = 11 | "dark:bg-gray-900 border border-gray-300 dark:border-gray-600 px-3 py-1 text-lg rounded-lg" 12 | 13 | export const Textfield = React.forwardRef( 14 | (props, ref) => { 15 | const { testId, ...rest } = props 16 | 17 | return ( 18 | 25 | ) 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /src/components/CopyButton/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/react" 2 | import { CopyButton } from "./" 3 | 4 | const textToCopy = "Hello" 5 | 6 | jest.mock("copy-to-clipboard", () => { 7 | return jest.fn() 8 | }) 9 | 10 | describe("CopyButton", () => { 11 | it("renders a ", () => { 12 | const { getByTestId } = render() 13 | expect(getByTestId("copy-button")).toHaveTextContent("Copy") 14 | }) 15 | 16 | it("fires an event when clicked", () => { 17 | const { getByTestId } = render() 18 | fireEvent.click(getByTestId("copy-button")) 19 | expect(getByTestId("copy-button")).toHaveTextContent("Copied!") 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/CopyButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../Button" 2 | import useCopy from "../../hooks/useCopy" 3 | 4 | interface ICopyButtonProps { 5 | label?: string 6 | textToCopy: string 7 | className?: string 8 | } 9 | 10 | export const CopyButton = (props: ICopyButtonProps) => { 11 | const { textToCopy, className } = props 12 | const { isCopied, copy } = useCopy() 13 | const buttonLabel = props.label || "Copy" 14 | 15 | return ( 16 |
17 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /scripts/set-manifest-content-script.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }) 3 | const fs = require("fs") 4 | const manifestPath = "./build/manifest.json" 5 | 6 | const run = async () => { 7 | const json = await fs.promises.readFile(manifestPath, "utf-8") 8 | const manifest = JSON.parse(json) 9 | 10 | // Ensure URL has scheme, defaulting to HTTPS 11 | const baseUrl = process.env.REACT_APP_SHARE_TARGET_URL || 'https://localhost:3000' 12 | const url = baseUrl.startsWith('http') ? baseUrl : `https://${baseUrl}` 13 | 14 | manifest.content_scripts[0].matches = [ 15 | `${url}/draft?*` 16 | ] 17 | 18 | await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) 19 | } 20 | run() 21 | -------------------------------------------------------------------------------- /src/hooks/useFormattedCode.ts: -------------------------------------------------------------------------------- 1 | import PrettierGraphQLParsers from "prettier/parser-graphql" 2 | import prettier from "prettier/standalone" 3 | import { useMemo } from "react" 4 | 5 | export function useFormattedCode( 6 | code: string, 7 | language: string, 8 | enabled = true 9 | ) { 10 | return useMemo(() => { 11 | if (enabled) { 12 | if (language === "graphql") { 13 | try { 14 | return prettier.format(code, { 15 | parser: language, 16 | useTabs: false, 17 | tabWidth: 2, 18 | plugins: [PrettierGraphQLParsers], 19 | }) 20 | } catch (e) { 21 | console.error(e) 22 | } 23 | } 24 | } 25 | 26 | return code 27 | }, [code, language, enabled]) 28 | } 29 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | 7 | Object.defineProperty(window, 'matchMedia', { 8 | writable: true, 9 | value: (query: any) => ({ 10 | matches: false, 11 | media: query, 12 | onchange: null, 13 | addEventListener: jest.fn(), 14 | removeEventListener: jest.fn(), 15 | dispatchEvent: jest.fn(), 16 | }), 17 | }) 18 | 19 | window.HTMLElement.prototype.scrollIntoView = function () {} 20 | 21 | window.TextDecoder = require('util').TextDecoder 22 | window.TextEncoder = require('util').TextEncoder 23 | -------------------------------------------------------------------------------- /src/helpers/jwt.test.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from "./jwt" 2 | 3 | describe("jwt.decodeJWT", () => { 4 | it("decodes a JWT token", () => { 5 | const token = 6 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" 7 | 8 | const decoded = jwt.decode(token) 9 | 10 | expect(decoded).toEqual({ 11 | sub: "1234567890", 12 | name: "John Doe", 13 | iat: 1516239022, 14 | }) 15 | }) 16 | 17 | it("fails to decode an invalid JWT token", () => { 18 | const token = "not-a-token" 19 | 20 | try { 21 | jwt.decode(token) 22 | } catch (error) { 23 | expect((error as Error).message).toBe("Invalid token") 24 | } 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/services/userSettingsService.ts: -------------------------------------------------------------------------------- 1 | import { chromeProvider } from './chromeProvider' 2 | 3 | export interface IUserSettings { 4 | isPreserveLogsActive: boolean 5 | isInvertFilterActive: boolean 6 | isRegexActive: boolean 7 | filter: string 8 | websocketUrlFilter: string 9 | shouldShowFullWebsocketMessage: boolean 10 | } 11 | 12 | export const getUserSettings = ( 13 | cb: (settings: Partial) => void 14 | ) => { 15 | const chrome = chromeProvider() 16 | chrome.storage.local.get('userSettings', (result) => { 17 | cb(result.userSettings || {}) 18 | }) 19 | } 20 | 21 | export const setUserSettings = (userSettings: Partial): void => { 22 | const chrome = chromeProvider() 23 | chrome.storage.local.set({ userSettings }) 24 | } 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Chrome Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /src/components/CopyAsCurlButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { ICompleteNetworkRequest } from "@/helpers/networkHelpers" 2 | import { Button } from "../Button" 3 | import { useCopyCurl } from "@/hooks/useCopyCurl/useCopyCurl" 4 | 5 | interface ICopyAsCurlButtonProps { 6 | networkRequest?: ICompleteNetworkRequest 7 | className?: string 8 | } 9 | 10 | export const CopyAsCurlButton = ({ networkRequest, className }: ICopyAsCurlButtonProps) => { 11 | const { copyAsCurl, isCopied } = useCopyCurl() 12 | 13 | return ( 14 |
15 | 22 |
23 | ) 24 | } -------------------------------------------------------------------------------- /src/components/AutoFormatToggleButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "../Button" 2 | import { CodeIcon } from "../Icons/CodeIcon" 3 | 4 | interface IAutoFormatToggleButtonProps { 5 | active?: boolean 6 | onToggle?: (value: boolean) => void 7 | className?: string 8 | } 9 | 10 | export const AutoFormatToggleButton = (props: IAutoFormatToggleButtonProps) => { 11 | const { active, onToggle, className } = props 12 | 13 | return ( 14 |
15 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/DelayedLoader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import useDelay from "@/hooks/useDelay" 3 | 4 | interface IDelayedLoaderProps { 5 | loading: boolean 6 | loader: React.ReactNode 7 | delay?: number 8 | } 9 | 10 | /** 11 | * Rendering a loading indicator when content is loading. 12 | * But delay loadint the indicator before the delay has passed. 13 | * 14 | * This improves the perceived performance of the app as the loader 15 | * does not flash on screen for short periods of time. 16 | * 17 | */ 18 | export const DelayedLoader: React.FC = (props) => { 19 | const { loading, loader, delay, children } = props 20 | const delayExeeded = useDelay(loading, delay) 21 | 22 | if (loading && delayExeeded) { 23 | return <>{loader} 24 | } 25 | 26 | return <>{children} 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/useCopyCurl/useCopyCurl.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { getNetworkCurl } from '@/helpers/curlHelpers' 3 | import useCopy from '../useCopy' 4 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 5 | 6 | export const useCopyCurl = () => { 7 | const { copy, isCopied } = useCopy() 8 | 9 | const copyAsCurl = useCallback( 10 | async (networkRequest: ICompleteNetworkRequest) => { 11 | if (!networkRequest) { 12 | console.warn('No network request data available') 13 | return 14 | } 15 | 16 | try { 17 | const curl = await getNetworkCurl(networkRequest) 18 | copy(curl) 19 | } catch (error) { 20 | console.error('Failed to generate cURL command:', error) 21 | } 22 | }, 23 | [copy] 24 | ) 25 | 26 | return { copyAsCurl, isCopied } 27 | } 28 | -------------------------------------------------------------------------------- /src/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import { RenderOptions, render } from "@testing-library/react" 2 | import { ReactElement } from "react" 3 | import { OperationFiltersProvider } from "./hooks/useOperationFilters" 4 | import { ShareMessageProvider } from "./hooks/useShareMessage" 5 | 6 | interface ITestRenderWrapperProps { 7 | children: ReactElement 8 | } 9 | 10 | const TestRenderWrapper: React.FC = (props) => { 11 | const { children } = props 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | 20 | const customRender = ( 21 | ui: ReactElement, 22 | options?: Omit 23 | ) => { 24 | return render(ui, { wrapper: TestRenderWrapper, ...options }) 25 | } 26 | 27 | export { customRender as render } 28 | -------------------------------------------------------------------------------- /src/components/Icons/DocsIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface IDocsIconProps extends SVGAttributes<{}> {} 4 | 5 | export const DocsIcon = (props: IDocsIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 17 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { chromeProvider } from "../services/chromeProvider" 3 | 4 | export const useDarkTheme = () => { 5 | const chrome = chromeProvider() 6 | const isDark = chrome.devtools.panels.themeName === "dark" 7 | // const isDark = false 8 | 9 | // Switch out the css for highlight.js depending on the theme 10 | useEffect(() => { 11 | const darkThemeLink = document.getElementById("highlightjs-dark-theme") 12 | const lightThemeLink = document.getElementById("highlightjs-light-theme") 13 | 14 | if (isDark) { 15 | lightThemeLink?.setAttribute("disabled", "disabled") 16 | darkThemeLink?.removeAttribute("disabled") 17 | } else { 18 | darkThemeLink?.setAttribute("disabled", "disabled") 19 | lightThemeLink?.removeAttribute("disabled") 20 | } 21 | }, [isDark]) 22 | 23 | return isDark 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Button/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/react" 2 | import { Button } from "./" 3 | 4 | const testId = "button" 5 | 6 | describe("Button", () => { 7 | it("renders a ) 9 | expect(getByTestId(testId)).toHaveTextContent("Hello") 10 | }) 11 | 12 | it("fires an event when clicked", () => { 13 | const onClickFn = jest.fn() 14 | 15 | const { getByTestId } = render( 16 |
28 | } 29 | /> 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Icons/MockIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from "react" 2 | 3 | interface IMockIconProps extends SVGAttributes<{}> {} 4 | 5 | export const MockIcon = (props: IMockIconProps) => { 6 | const { width = "1.5rem", height = "1.5rem" } = props 7 | return ( 8 | 18 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useNetworkTabs.tsx: -------------------------------------------------------------------------------- 1 | import { useState, createContext, useContext } from "react" 2 | 3 | export enum NetworkTabs { 4 | HEADER, 5 | REQUEST, 6 | RESPONSE, 7 | RESPONSE_RAW, 8 | } 9 | 10 | const NetworkTabsContext = createContext<{ 11 | activeTab: number 12 | setActiveTab: React.Dispatch> 13 | }>({ 14 | activeTab: NetworkTabs.REQUEST, 15 | setActiveTab: () => null, 16 | }) 17 | 18 | export const NetworkTabsProvider: React.FC = ({ children }) => { 19 | const [activeTab, setActiveTab] = useState(NetworkTabs.REQUEST) 20 | 21 | return ( 22 | 28 | {children} 29 | 30 | ) 31 | } 32 | 33 | export const useNetworkTabs = () => { 34 | const { activeTab, setActiveTab } = useContext(NetworkTabsContext) 35 | 36 | return { 37 | activeTab, 38 | setActiveTab, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Popover/index.tsx: -------------------------------------------------------------------------------- 1 | import { Popover as HeadlessPopover } from "@headlessui/react" 2 | 3 | interface IPopoverProps { 4 | position?: { top?: number; bottom?: number; left?: number; right?: number } 5 | className?: string 6 | button: React.ReactNode 7 | children: React.ReactNode 8 | } 9 | 10 | export const Popover = (props: IPopoverProps) => { 11 | const { position, button, children, className } = props 12 | 13 | return ( 14 | 15 | {button} 16 | 17 | 21 |
22 | {children} 23 |
24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/SearchInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react" 2 | import { useDebouncedEffect } from "../../hooks/useDebouncedEffect" 3 | import { Textfield } from "../Textfield" 4 | 5 | interface ISearchInputProps { 6 | className?: string 7 | onSearch: (query: string) => void 8 | } 9 | 10 | export const SearchInput = (props: ISearchInputProps) => { 11 | const [value, setValue] = useState("") 12 | const { onSearch, className } = props 13 | 14 | const runSearch = useCallback(() => { 15 | onSearch(value) 16 | }, [onSearch, value]) 17 | useDebouncedEffect(runSearch, []) 18 | 19 | return ( 20 | setValue(event.target.value)} 22 | onBlur={runSearch} 23 | onKeyDown={(event) => { 24 | if (event.key === "Enter") { 25 | runSearch() 26 | } 27 | }} 28 | placeholder="Search full request" 29 | className={className} 30 | testId="search-input" 31 | type="search" 32 | autoFocus 33 | /> 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | interface ICheckboxProps { 2 | id?: string 3 | label: string 4 | className?: string 5 | checked: boolean 6 | onChange: (value: boolean) => void 7 | testId?: string 8 | } 9 | 10 | export const Checkbox = (props: ICheckboxProps) => { 11 | const { id, label, className, onChange, checked, testId } = props 12 | 13 | const toggleChecked = () => { 14 | onChange(!checked) 15 | } 16 | 17 | return ( 18 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkDetails/TracingView.tsx: -------------------------------------------------------------------------------- 1 | import { TracingVisualization } from "@/components/TracingVisualization" 2 | import { useApolloTracing } from "@/hooks/useApolloTracing" 3 | import { useByteSize } from "@/hooks/useBytes" 4 | import { config } from "@/config" 5 | import { Maybe } from '@/types' 6 | 7 | interface ITracingViewProps { 8 | response: Maybe 9 | } 10 | 11 | export const TracingView = (props: ITracingViewProps) => { 12 | const { response } = props 13 | const tracing = useApolloTracing(response) 14 | const size = useByteSize(response?.length || 0, { unit: "mb" }) 15 | 16 | if (size > config.maxUsableResponseSizeMb) { 17 | return ( 18 |
19 | The output is too large to display. 20 |
21 | ) 22 | } 23 | 24 | return ( 25 |
26 | {tracing ? ( 27 | 28 | ) : ( 29 |

No tracing found.

30 | )} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/useBoundingRect.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef, useState } from "react" 2 | import { debounce } from "@/helpers/debounce" 3 | 4 | interface Listener { 5 | (event?: UIEvent): void 6 | } 7 | 8 | export const useBoundingRect = () => { 9 | const container = useRef(null) 10 | 11 | const [width, setWidth] = useState(0) 12 | const [height, setHeight] = useState(0) 13 | 14 | useLayoutEffect(() => { 15 | const instantUpdate: Listener = () => { 16 | if (container.current) { 17 | const { width, height } = container.current.getBoundingClientRect() 18 | setWidth(width) 19 | setHeight(height) 20 | } 21 | } 22 | 23 | // initialize 24 | instantUpdate() 25 | 26 | // on resize 27 | const debouncedUpdate = debounce(instantUpdate, 600) 28 | window.addEventListener("resize", debouncedUpdate) 29 | 30 | return () => window.removeEventListener("resize", debouncedUpdate) 31 | }, []) 32 | 33 | return { 34 | container, 35 | width, 36 | height, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useUserSettings.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { 3 | getUserSettings, 4 | IUserSettings, 5 | setUserSettings, 6 | } from '../services/userSettingsService' 7 | 8 | const useUserSettings = () => { 9 | const [settings, setSettings] = useState({ 10 | isPreserveLogsActive: false, 11 | isInvertFilterActive: false, 12 | isRegexActive: false, 13 | filter: '', 14 | websocketUrlFilter: '', 15 | shouldShowFullWebsocketMessage: true, 16 | }) 17 | 18 | // Load initial settings on component mount 19 | useEffect(() => { 20 | getUserSettings((userSettings) => { 21 | setSettings((prevSettings) => { 22 | return { ...prevSettings, ...userSettings } 23 | }) 24 | }) 25 | }, []) 26 | 27 | const setSettingsProxy = (newSettings: Partial) => { 28 | setUserSettings({ ...settings, ...newSettings }) 29 | setSettings({ ...settings, ...newSettings }) 30 | } 31 | 32 | return [settings, setSettingsProxy] as const 33 | } 34 | 35 | export default useUserSettings 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test App 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | timeout-minutes: 15 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18] 13 | 14 | steps: 15 | - name: Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.4.1 17 | with: 18 | access_token: ${{ github.token }} 19 | 20 | - uses: actions/checkout@v1 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Install Yarn 28 | run: | 29 | npm install yarn -g 30 | 31 | - name: install 32 | run: | 33 | yarn install 34 | 35 | - name: check types 36 | run: | 37 | yarn check-types 38 | 39 | - name: lint 40 | run: | 41 | yarn lint 42 | 43 | - name: test 44 | run: | 45 | yarn test 46 | env: 47 | CI: true 48 | -------------------------------------------------------------------------------- /src/components/Icons/LearnIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react' 2 | 3 | interface ILearnIconProps extends SVGAttributes<{}> {} 4 | 5 | export const LearnIcon = (props: ILearnIconProps) => { 6 | const { width = '1.5rem', height = '1.5rem' } = props 7 | return ( 8 | 17 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | const defaultOptions = { isRunning: true } 4 | 5 | /** 6 | * Call a function every `ms` milliseconds. Ensure 7 | * the callback given is memoized with useCallback. 8 | * 9 | */ 10 | const useInterval = ( 11 | cb: () => void, 12 | ms: number, 13 | options: { isRunning: boolean } = defaultOptions 14 | ) => { 15 | const { isRunning } = options 16 | 17 | // Run once on mount 18 | useEffect(() => { 19 | if (!isRunning) { 20 | return 21 | } 22 | 23 | const run = async () => { 24 | await cb() 25 | } 26 | run() 27 | }, [cb, isRunning]) 28 | 29 | // Run every `ms` milliseconds 30 | useEffect(() => { 31 | let timer: ReturnType 32 | const run = () => { 33 | if (!isRunning) { 34 | return 35 | } 36 | 37 | timer = setTimeout(async () => { 38 | await cb() 39 | run() 40 | }, ms) 41 | } 42 | run() 43 | 44 | return () => clearTimeout(timer) 45 | }, [cb, ms, isRunning]) 46 | } 47 | 48 | export default useInterval 49 | -------------------------------------------------------------------------------- /src/components/TracingVisualization/useTracingVirtualization.ts: -------------------------------------------------------------------------------- 1 | import { IApolloServerTracing, IApolloServerTracingResolvers, Maybe } from "@/types" 2 | import { useRef, useCallback, MutableRefObject, useMemo } from 'react' 3 | import { useVirtualization } from '@/hooks/useVirtualization' 4 | 5 | const EMPTY_ARRAY: IApolloServerTracingResolvers[] = [] 6 | 7 | interface Results extends ReturnType { 8 | ref: MutableRefObject; 9 | resolvers: IApolloServerTracingResolvers[]; 10 | } 11 | 12 | export const useTracingVirtualization = (tracing: Maybe): Results => { 13 | const resolvers = tracing?.execution.resolvers || EMPTY_ARRAY; 14 | 15 | const parentRef = useRef(null) 16 | const rowVirtualizer = useVirtualization({ 17 | size: resolvers.length, 18 | parentRef, 19 | estimateSize: useCallback(() => 20, []), 20 | overscan: 5, 21 | paddingEnd: 20, 22 | }) 23 | 24 | const memoResults = useMemo(() => ({ 25 | ref: parentRef, 26 | resolvers, 27 | ...rowVirtualizer, 28 | }), [resolvers, rowVirtualizer]) 29 | 30 | return memoResults; 31 | } -------------------------------------------------------------------------------- /src/helpers/getSearchContent.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './safeJson' 2 | import { IHeader, ICompleteNetworkRequest } from '@/helpers/networkHelpers' 3 | 4 | const stringifyHeaders = (headers: IHeader[] = []) => { 5 | return headers 6 | .map((header) => { 7 | return `${header.name}: ${header.value}` 8 | }) 9 | .join(', ') 10 | } 11 | 12 | export const getHeaderSearchContent = ( 13 | networkRequest: ICompleteNetworkRequest 14 | ): string => { 15 | const requestHeaderText = stringifyHeaders(networkRequest.request.headers) 16 | const responseHeaderText = stringifyHeaders(networkRequest.response?.headers) 17 | return [requestHeaderText, responseHeaderText].join(', ') 18 | } 19 | 20 | export const getRequestSearchContent = ( 21 | networkRequest: ICompleteNetworkRequest 22 | ): string => { 23 | return networkRequest.request.body 24 | .map((body) => { 25 | return body.query + ' ' + stringify(body.variables) 26 | }) 27 | .join(', ') 28 | } 29 | 30 | export const getResponseSearchContent = ( 31 | networkRequest: ICompleteNetworkRequest 32 | ): string => { 33 | return networkRequest.response?.body || '' 34 | } 35 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 GraphQL Network Inspector authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/CodeView/JsonView.tsx: -------------------------------------------------------------------------------- 1 | import ReactJson from '@notdutzi/react-json-view' 2 | import { useDarkTheme } from '../../hooks/useTheme' 3 | import { useState } from 'react' 4 | 5 | interface IJsonViewProps { 6 | src: object 7 | collapsed?: number 8 | } 9 | 10 | export const JsonView = (props: IJsonViewProps) => { 11 | const isDarkTheme = useDarkTheme() 12 | const [expandAll, setExpandAll] = useState(false) 13 | 14 | const handleClick = (e: React.MouseEvent) => { 15 | if (e.ctrlKey || e.metaKey) { 16 | setExpandAll(!expandAll) 17 | } 18 | } 19 | 20 | return ( 21 |
22 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/useLatestState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react' 2 | 3 | /** 4 | * Has a matching API to useState, but also provides a getter to always 5 | * access the latest state. 6 | * 7 | * This is handled through a ref, so it's safe to use in callbacks or 8 | * other places where the state might be stale. 9 | * 10 | */ 11 | const useLatestState = (initialState: T) => { 12 | const [state, setState] = useState(initialState) 13 | const latestStateRef = useRef(state) 14 | 15 | // This getter can be used to always access the latest state 16 | const getState = useCallback(() => latestStateRef.current, []) 17 | 18 | const setStateWrapper = useCallback( 19 | (newState: T | ((state: T) => T)) => { 20 | setState((prevState) => { 21 | const updatedState = 22 | typeof newState === 'function' 23 | ? (newState as any)(prevState) 24 | : newState 25 | latestStateRef.current = updatedState 26 | return updatedState 27 | }) 28 | }, 29 | [setState] 30 | ) 31 | 32 | return [state, setStateWrapper, getState] as const 33 | } 34 | 35 | export default useLatestState 36 | -------------------------------------------------------------------------------- /src/hooks/useOperationFilters.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react" 2 | import { OperationType } from "../helpers/graphqlHelpers" 3 | 4 | export type IOperationFilters = Record 5 | 6 | interface IOperationFilterContext { 7 | operationFilters: IOperationFilters 8 | setOperationFilters: React.Dispatch> 9 | } 10 | 11 | const OperationFilersContext = createContext(null!) 12 | 13 | export const OperationFiltersProvider: React.FC<{}> = ({ children }) => { 14 | const [operationFilters, setOperationFilters] = useState({ 15 | query: true, 16 | mutation: true, 17 | subscription: false, 18 | persisted: true, 19 | }) 20 | 21 | return ( 22 | 25 | {children} 26 | 27 | ) 28 | } 29 | 30 | export const useOperationFilters = () => { 31 | const context = useContext(OperationFilersContext) 32 | 33 | if (!context) { 34 | throw new Error( 35 | "useOperationFilters must be used within a OperationFiltersProvider" 36 | ) 37 | } 38 | 39 | return context 40 | } 41 | -------------------------------------------------------------------------------- /src/hooks/useRequestViewSections.tsx: -------------------------------------------------------------------------------- 1 | import { useState, createContext, useContext } from "react" 2 | 3 | export type RequestViewSectionType = "query" | "variables" | "extensions" | 'request' 4 | 5 | const RequestViewSectionsContext = createContext<{ 6 | collapsedSections: Partial> 7 | setIsSectionCollapsed: ( 8 | sectionId: string, 9 | isCollapsed: boolean 10 | ) => void 11 | }>({ 12 | collapsedSections: {}, 13 | setIsSectionCollapsed: () => null, 14 | }) 15 | 16 | export const RequestViewSectionsProvider: React.FC = ({ children }) => { 17 | const [collapsedSections, setCollapsedSections] = useState( 18 | {} as Partial> 19 | ) 20 | 21 | function setIsSectionCollapsed( 22 | sectionId: string, 23 | isCollapsed: boolean 24 | ) { 25 | setCollapsedSections({ 26 | ...collapsedSections, 27 | [sectionId]: isCollapsed, 28 | }) 29 | } 30 | 31 | return ( 32 | 35 | {children} 36 | 37 | ) 38 | } 39 | 40 | export const useRequestViewSections = () => { 41 | return useContext(RequestViewSectionsContext) 42 | } 43 | -------------------------------------------------------------------------------- /src/helpers/searchString.ts: -------------------------------------------------------------------------------- 1 | interface ISearchStringArgs { 2 | text: string 3 | search: string 4 | buffer?: number 5 | } 6 | 7 | const escapeRegExp = (string: string) => { 8 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string 9 | } 10 | 11 | const getSearchInput = (search: string) => { 12 | const searchString = escapeRegExp(search) 13 | try { 14 | return new RegExp(searchString, 'i') 15 | } catch (e) { 16 | return searchString 17 | } 18 | } 19 | 20 | export const searchString = ({ 21 | text, 22 | search, 23 | buffer = 12, 24 | }: ISearchStringArgs) => { 25 | const searchInput = getSearchInput(search) 26 | const matchPosition = text.search(searchInput) 27 | 28 | if (matchPosition === -1) { 29 | return { 30 | start: '', 31 | match: '', 32 | end: '', 33 | } 34 | } 35 | 36 | const highlightLength = search.length 37 | const matchPositionEnd = matchPosition + highlightLength 38 | 39 | const start = text.slice(Math.max(0, matchPosition - buffer), matchPosition) 40 | const match = text.slice(matchPosition, matchPositionEnd) 41 | const end = text.slice(matchPositionEnd, matchPositionEnd + buffer) 42 | 43 | return { 44 | start, 45 | match, 46 | end, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Tabs/Tabs.test.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { render, fireEvent } from "@testing-library/react" 3 | import { Tabs, ITab } from "./index" 4 | 5 | const tabs: ITab[] = [ 6 | { 7 | title: "Tab One", 8 | component:
I am tab one
, 9 | }, 10 | { 11 | title: "Tab Two", 12 | component:
I am tab two
, 13 | }, 14 | ] 15 | 16 | const ControlledTabs = (props: { tabs: ITab[] }) => { 17 | const [activeTab, setActiveTab] = useState(0) 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | describe("Tabs", () => { 24 | it("renders the first tab as the default active tab", () => { 25 | const { queryByText } = render() 26 | 27 | expect(queryByText(/I am tab one/)).toBeInTheDocument() 28 | expect(queryByText(/I am tab two/)).not.toBeInTheDocument() 29 | }) 30 | 31 | it("changes the active tab when button clicked", () => { 32 | const { getByText, queryByText } = render() 33 | 34 | fireEvent.click(getByText(/Tab Two/)) 35 | 36 | expect(queryByText(/I am tab one/)).not.toBeInTheDocument() 37 | expect(queryByText(/I am tab two/)).toBeInTheDocument() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/hooks/useSearchStart.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | export const useSearchStart = (cb: () => void) => { 4 | useEffect(() => { 5 | // Attaching event to body allows stopPropagation 6 | // to block inbuilt search bar from appearing 7 | const body = document.querySelector("body") 8 | if (!body) { 9 | return 10 | } 11 | 12 | const getIsCommandKeyPressed = (event: KeyboardEvent) => { 13 | return event.code === "MetaLeft" || event.code === "ControlLeft" 14 | } 15 | 16 | let isCommandKeyPressed = false 17 | const handleKeyDown = (event: KeyboardEvent) => { 18 | if (getIsCommandKeyPressed(event)) { 19 | isCommandKeyPressed = true 20 | } else if (event.code === "KeyF" && isCommandKeyPressed) { 21 | event.preventDefault() 22 | event.stopPropagation() 23 | cb() 24 | } 25 | } 26 | const handleKeyUp = (event: KeyboardEvent) => { 27 | if (getIsCommandKeyPressed(event)) { 28 | isCommandKeyPressed = false 29 | } 30 | } 31 | body.addEventListener("keydown", handleKeyDown) 32 | body.addEventListener("keyup", handleKeyUp) 33 | 34 | return () => { 35 | body.removeEventListener("keydown", handleKeyDown) 36 | body.removeEventListener("keyup", handleKeyUp) 37 | } 38 | }, [cb]) 39 | } 40 | -------------------------------------------------------------------------------- /public/css/atom-one-dark.css: -------------------------------------------------------------------------------- 1 | pre code.hljs { 2 | display: block; 3 | overflow-x: auto; 4 | padding: 1em; 5 | } 6 | code.hljs { 7 | padding: 3px 5px; 8 | } 9 | .hljs { 10 | color: #abb2bf; 11 | background: #282c34; 12 | } 13 | .hljs-comment, 14 | .hljs-quote { 15 | color: #5c6370; 16 | font-style: italic; 17 | } 18 | .hljs-doctag, 19 | .hljs-formula, 20 | .hljs-keyword { 21 | color: #c678dd; 22 | } 23 | .hljs-deletion, 24 | .hljs-name, 25 | .hljs-section, 26 | .hljs-selector-tag, 27 | .hljs-subst { 28 | color: #e06c75; 29 | } 30 | .hljs-literal { 31 | color: #56b6c2; 32 | } 33 | .hljs-addition, 34 | .hljs-attribute, 35 | .hljs-meta .hljs-string, 36 | .hljs-regexp, 37 | .hljs-string { 38 | color: #98c379; 39 | } 40 | .hljs-attr, 41 | .hljs-number, 42 | .hljs-selector-attr, 43 | .hljs-selector-class, 44 | .hljs-selector-pseudo, 45 | .hljs-template-variable, 46 | .hljs-type, 47 | .hljs-variable { 48 | color: #d19a66; 49 | } 50 | .hljs-bullet, 51 | .hljs-link, 52 | .hljs-meta, 53 | .hljs-selector-id, 54 | .hljs-symbol, 55 | .hljs-title { 56 | color: #61aeee; 57 | } 58 | .hljs-built_in, 59 | .hljs-class .hljs-title, 60 | .hljs-title.class_ { 61 | color: #e6c07b; 62 | } 63 | .hljs-emphasis { 64 | font-style: italic; 65 | } 66 | .hljs-strong { 67 | font-weight: 700; 68 | } 69 | .hljs-link { 70 | text-decoration: underline; 71 | } 72 | -------------------------------------------------------------------------------- /public/css/atom-one-light.css: -------------------------------------------------------------------------------- 1 | pre code.hljs { 2 | display: block; 3 | overflow-x: auto; 4 | padding: 1em; 5 | } 6 | code.hljs { 7 | padding: 3px 5px; 8 | } 9 | .hljs { 10 | color: #383a42; 11 | background: #fafafa; 12 | } 13 | .hljs-comment, 14 | .hljs-quote { 15 | color: #a0a1a7; 16 | font-style: italic; 17 | } 18 | .hljs-doctag, 19 | .hljs-formula, 20 | .hljs-keyword { 21 | color: #a626a4; 22 | } 23 | .hljs-deletion, 24 | .hljs-name, 25 | .hljs-section, 26 | .hljs-selector-tag, 27 | .hljs-subst { 28 | color: #e45649; 29 | } 30 | .hljs-literal { 31 | color: #0184bb; 32 | } 33 | .hljs-addition, 34 | .hljs-attribute, 35 | .hljs-meta .hljs-string, 36 | .hljs-regexp, 37 | .hljs-string { 38 | color: #50a14f; 39 | } 40 | .hljs-attr, 41 | .hljs-number, 42 | .hljs-selector-attr, 43 | .hljs-selector-class, 44 | .hljs-selector-pseudo, 45 | .hljs-template-variable, 46 | .hljs-type, 47 | .hljs-variable { 48 | color: #986801; 49 | } 50 | .hljs-bullet, 51 | .hljs-link, 52 | .hljs-meta, 53 | .hljs-selector-id, 54 | .hljs-symbol, 55 | .hljs-title { 56 | color: #4078f2; 57 | } 58 | .hljs-built_in, 59 | .hljs-class .hljs-title, 60 | .hljs-title.class_ { 61 | color: #c18401; 62 | } 63 | .hljs-emphasis { 64 | font-style: italic; 65 | } 66 | .hljs-strong { 67 | font-weight: 700; 68 | } 69 | .hljs-link { 70 | text-decoration: underline; 71 | } 72 | -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge" 2 | import { ReactElement } from "react" 3 | 4 | const baseStyle = ` 5 | py-1.5 px-2.5 text-md font-semibold rounded-md text-gray-600 dark:text-white z-10 6 | ` 7 | 8 | const styles = { 9 | primary: ` 10 | bg-gray-200 dark:bg-gray-700 11 | hover:bg-gray-300 dark:hover:bg-gray-600 12 | `, 13 | ghost: `hover:bg-gray-300 dark:hover:bg-gray-600 hover:text-gray-600 hover:dark:text-white z-10`, 14 | } 15 | 16 | interface IButtonProps { 17 | onClick?: () => void 18 | className?: string 19 | variant?: "primary" | "ghost" 20 | size?: "sm" | "md" | "lg" 21 | children?: React.ReactNode 22 | icon?: ReactElement 23 | testId?: string 24 | } 25 | 26 | export const Button = (props: IButtonProps) => { 27 | const { 28 | children, 29 | onClick, 30 | variant = "primary", 31 | className, 32 | icon, 33 | testId, 34 | } = props 35 | 36 | const computedClassName = twMerge(baseStyle, [styles[variant]], className) 37 | 38 | return ( 39 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionResult } from "graphql" 2 | 3 | export type Maybe = T | null | undefined 4 | 5 | export interface IResponseBody 6 | extends ExecutionResult {} 7 | 8 | export interface IApolloServerExtensions { 9 | tracing?: IApolloServerTracing 10 | } 11 | 12 | // Incremental delivery (defer/stream) support 13 | export interface IIncrementalDataChunk { 14 | data?: unknown 15 | path?: (string | number)[] 16 | label?: string 17 | errors?: Array<{ 18 | message: string 19 | path?: (string | number)[] 20 | }> 21 | extensions?: IApolloServerExtensions 22 | hasNext?: boolean 23 | } 24 | 25 | export interface IIncrementalResponse { 26 | data?: unknown 27 | errors?: Array<{ 28 | message: string 29 | path?: (string | number)[] 30 | }> 31 | extensions?: IApolloServerExtensions 32 | hasNext: boolean 33 | incremental?: IIncrementalDataChunk[] 34 | } 35 | 36 | export interface IApolloServerTracing { 37 | version: number 38 | startTime: string 39 | endTime: string 40 | duration: number 41 | execution: { 42 | resolvers: IApolloServerTracingResolvers[] 43 | } 44 | } 45 | 46 | export interface IApolloServerTracingResolvers { 47 | path: Array 48 | parentType: string 49 | fieldName: string 50 | returnType: string 51 | startOffset: number 52 | duration: number 53 | } 54 | -------------------------------------------------------------------------------- /src/containers/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { SearchProvider } from "../../hooks/useSearch" 2 | import { NetworkTabsProvider } from "../../hooks/useNetworkTabs" 3 | import { useDarkTheme } from "../../hooks/useTheme" 4 | import { Main } from "../Main" 5 | import { RequestViewSectionsProvider } from "@/hooks/useRequestViewSections" 6 | import { ShareMessageProvider } from "../../hooks/useShareMessage" 7 | import { OperationFiltersProvider } from "../../hooks/useOperationFilters" 8 | import { ErrorBoundary } from "../../components/ErrorBoundary" 9 | 10 | export const App = () => { 11 | const isDarkTheme = useDarkTheme() 12 | 13 | return ( 14 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useSearch.tsx: -------------------------------------------------------------------------------- 1 | import { useState, createContext, useContext, useCallback } from "react" 2 | import { useSearchStart } from "./useSearchStart" 3 | 4 | const SearchContext = createContext<{ 5 | searchQuery: string 6 | setSearchQuery: React.Dispatch> 7 | isSearchOpen: boolean 8 | setIsSearchOpen: React.Dispatch> 9 | }>({ 10 | searchQuery: "", 11 | setSearchQuery: () => null, 12 | isSearchOpen: false, 13 | setIsSearchOpen: () => null, 14 | }) 15 | 16 | export const SearchProvider: React.FC = ({ children }) => { 17 | const [searchQuery, setSearchQuery] = useState("") 18 | const [isSearchOpen, setIsSearchOpen] = useState(false) 19 | 20 | const handleSearchStart = useCallback(() => { 21 | setIsSearchOpen(true) 22 | }, [setIsSearchOpen]) 23 | useSearchStart(handleSearchStart) 24 | 25 | return ( 26 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | export const useSearch = () => { 40 | const { searchQuery, setSearchQuery, isSearchOpen, setIsSearchOpen } = 41 | useContext(SearchContext) 42 | 43 | return { 44 | searchQuery, 45 | setSearchQuery, 46 | isSearchOpen, 47 | setIsSearchOpen, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/HeaderView/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react" 2 | import { Panels, PanelSection } from "../PanelSection" 3 | import { CopyButton } from "../../../components/CopyButton" 4 | import { HeaderList } from "./HeaderList" 5 | import { IHeader } from "../../../helpers/networkHelpers" 6 | 7 | interface IHeaderViewProps { 8 | requestHeaders: IHeader[] 9 | responseHeaders: IHeader[] 10 | } 11 | 12 | export const HeaderView = (props: IHeaderViewProps) => { 13 | const { requestHeaders, responseHeaders } = props 14 | const headerStrings = useMemo(() => { 15 | return { 16 | requestHeaders: JSON.stringify(requestHeaders), 17 | responseHeaders: JSON.stringify(responseHeaders), 18 | } 19 | }, [requestHeaders, responseHeaders]) 20 | 21 | return ( 22 | 23 | 24 | 28 | 29 | 30 | 31 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useFormattedCode.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react" 2 | import { FunctionComponent } from "react" 3 | import { useFormattedCode } from "./useFormattedCode" 4 | 5 | const minifiedCode = `query {foo(bar:{baz:"hello world"}){id nested{thing}}}` 6 | 7 | const expectedFormattedCode = `query { 8 | foo(bar: { baz: "hello world" }) { 9 | id 10 | nested { 11 | thing 12 | } 13 | } 14 | } 15 | ` 16 | 17 | export function testSimpleReactHook(useTest: () => Value) { 18 | let result: null | Value = null 19 | const Component: FunctionComponent = () => { 20 | result = useTest() 21 | return null 22 | } 23 | render() 24 | return result 25 | } 26 | 27 | describe("useFormattedCode", () => { 28 | it("should format GraphQL code", () => { 29 | const result = testSimpleReactHook(() => 30 | useFormattedCode(minifiedCode, "graphql", true) 31 | ) 32 | expect(result).toBe(expectedFormattedCode) 33 | }) 34 | 35 | it("should return unchanged code when enabled=false", () => { 36 | const result = testSimpleReactHook(() => 37 | useFormattedCode(minifiedCode, "graphql", false) 38 | ) 39 | expect(result).toBe(minifiedCode) 40 | }) 41 | 42 | it("should return unchanged code when non-graphql language", () => { 43 | const json = JSON.stringify({ foo: { bar: "baz" } }) 44 | const result = testSimpleReactHook(() => 45 | useFormattedCode(json, "json", true) 46 | ) 47 | expect(result).toBe(json) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /public/contentScript_export.js: -------------------------------------------------------------------------------- 1 | // This file is injected into a page by chrome's content script. See: 2 | // https://developer.chrome.com/docs/extensions/develop/concepts/content-scripts 3 | // 4 | // The page this will run on is specified in the manifest.json file 5 | // under content_scripts.matches[] 6 | // 7 | // In our case, this will run on graphdev.app 8 | // 9 | // On page load we send a message which will be received by the 10 | // running chrome extension. The extension will then send back 11 | // a message with the current draft payload. 12 | // 13 | // We then insert the payload into the page dom so that the 14 | // webpage can read it out. 15 | 16 | // Inject the payload into the page dom. The page can poll this 17 | // element to get the payload. 18 | const insertDraftPayload = (payload) => { 19 | const div = document.createElement("div") 20 | div.id = "graphDev__draftPayload" 21 | div.style.display = "none" 22 | div.textContent = payload 23 | const elem = document.body || document.documentElement 24 | elem.appendChild(div) 25 | } 26 | 27 | // Pick up the sessionId from the url. 28 | const params = new URLSearchParams(window.location.search) 29 | const sessionId = params.get("sessionId") 30 | 31 | // Send ready status and receive draft payload from the extension. 32 | chrome.runtime.sendMessage( 33 | { message: "ready", sessionId }, 34 | function (response) { 35 | if (chrome.runtime.lastError) { 36 | console.log(chrome.runtime.lastError.message) 37 | return 38 | } 39 | 40 | if (response.message === "draft") { 41 | insertDraftPayload(response.payload) 42 | } 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /src/helpers/gzip.ts: -------------------------------------------------------------------------------- 1 | export type CompressionType = 'gzip' | 'deflate' 2 | 3 | const rawToUint8Array = (raw: chrome.webRequest.UploadData[]): Uint8Array => { 4 | const arrays = raw 5 | .filter((data) => data.bytes) 6 | .map((data) => new Uint8Array(data.bytes!)) 7 | 8 | const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0) 9 | const result = new Uint8Array(totalLength) 10 | 11 | let offset = 0 12 | for (const arr of arrays) { 13 | result.set(arr, offset) 14 | offset += arr.length 15 | } 16 | 17 | return result 18 | } 19 | 20 | const stringToUint8Array = (str: string): Uint8Array => { 21 | const array = new Uint8Array(str.length) 22 | for (let i = 0; i < str.length; i++) { 23 | array[i] = str.charCodeAt(i) 24 | } 25 | return array 26 | } 27 | 28 | export const decompress = async ( 29 | raw: chrome.webRequest.UploadData[] | string, 30 | compressionType: CompressionType 31 | ) => { 32 | const uint8Array = 33 | typeof raw === 'string' ? stringToUint8Array(raw) : rawToUint8Array(raw) 34 | 35 | const readableStream = new Response(uint8Array).body 36 | if (!readableStream) { 37 | throw new Error('Failed to create readable stream from Uint8Array.') 38 | } 39 | 40 | // Pipe through the decompression stream 41 | const decompressedStream = readableStream.pipeThrough( 42 | new (window as any).DecompressionStream(compressionType) 43 | ) 44 | 45 | // Convert the decompressed stream back to a Uint8Array 46 | const decompressedArrayBuffer = await new Response( 47 | decompressedStream 48 | ).arrayBuffer() 49 | 50 | return new Uint8Array(decompressedArrayBuffer) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/TracingVisualization/TracingVisualization.tsx: -------------------------------------------------------------------------------- 1 | import { IApolloServerTracing, Maybe } from "@/types" 2 | import { TracingVisualizationRow, useTracingVirtualization } from "." 3 | 4 | interface ITracingVisualizationProps { 5 | tracing: Maybe; 6 | } 7 | 8 | export const TracingVisualization = (props: ITracingVisualizationProps) => { 9 | const { tracing } = props 10 | const totalTimeNs = tracing?.duration || 0 11 | const { ref, resolvers, totalSize, virtualItems } = useTracingVirtualization(tracing) 12 | 13 | return ( 14 |
18 |
22 | 28 | 29 | {virtualItems.map(({ key, index, size, start }) => { 30 | const { parentType, path, startOffset, duration } = resolvers[index]; 31 | return ( 32 | 44 | ) 45 | })} 46 |
47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/HeaderView/parseAuthHeader.test.ts: -------------------------------------------------------------------------------- 1 | import parseAuthHeader from "./parseAuthHeader" 2 | 3 | describe("parseAuthHeader", () => { 4 | it("parses a bearer token header", () => { 5 | const header = { 6 | name: "Authorization", 7 | value: 8 | "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 9 | } 10 | 11 | const result = parseAuthHeader(header) 12 | expect(result).toEqual( 13 | '{"sub":"1234567890","name":"John Doe","iat":1516239022}' 14 | ) 15 | }) 16 | 17 | it("parses a non-bearer token header", () => { 18 | const header = { 19 | name: "Authorization", 20 | value: 21 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 22 | } 23 | 24 | const result = parseAuthHeader(header) 25 | expect(result).toEqual( 26 | '{"sub":"1234567890","name":"John Doe","iat":1516239022}' 27 | ) 28 | }) 29 | 30 | it("returns undefined if the header value is empty", () => { 31 | const header = { 32 | name: "Authorization", 33 | } 34 | 35 | const result = parseAuthHeader(header) 36 | expect(result).toBeUndefined() 37 | }) 38 | 39 | it("returns undefined if the header value is not a valid JWT", () => { 40 | const header = { 41 | name: "Authorization", 42 | value: "invalid", 43 | } 44 | 45 | const result = parseAuthHeader(header) 46 | expect(result).toBeUndefined() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | // Custom config on top of CRA see: 2 | // https://github.com/gsoft-inc/craco/blob/master/packages/craco/README.md#configuration 3 | 4 | const path = require("path") 5 | const CopyWebpackPlugin = require("copy-webpack-plugin") 6 | 7 | module.exports = ({ env }) => { 8 | const isEnvDevelopment = env === "development" 9 | 10 | return { 11 | style: { 12 | postcssOptions: { 13 | plugins: [require("tailwindcss"), require("autoprefixer")], 14 | }, 15 | }, 16 | webpack: { 17 | configure: { 18 | // To access source maps during development as an unpacked extension 19 | // we need source maps to be inline, otherwise chrome will not load 20 | // them corretly. 21 | devtool: isEnvDevelopment ? "inline-source-map" : false, 22 | resolve: { 23 | extensions: [".ts", ".tsx", ".json"], 24 | alias: { 25 | "@": path.resolve("src"), 26 | }, 27 | }, 28 | }, 29 | plugins: [ 30 | new CopyWebpackPlugin({ 31 | patterns: [ 32 | { 33 | from: "public", 34 | globOptions: { 35 | ignore: ["**/index.html"], 36 | }, 37 | }, 38 | ], 39 | }), 40 | ], 41 | }, 42 | devServer: { 43 | devMiddleware: { 44 | // This aid in debugging the source maps during development. 45 | // When loading an unpacked extension in chrome we can see the 46 | // source files and easily debug without needing to rebuild the 47 | // entire app. 48 | writeToDisk: true, 49 | }, 50 | }, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useMark.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react" 2 | import { useMark } from "./useMark" 3 | 4 | const TestComponent = (props: { search: string; done?: () => void }) => { 5 | const ref = useMark(props.search, "", props.done) 6 | return ( 7 |
8 | The quick brown fox jumps over the lazy dog again 9 |
10 | ) 11 | } 12 | 13 | describe("useMark", () => { 14 | it("should mark the searched text within the dom for a single letter", () => { 15 | const search = "a" 16 | 17 | const { container } = render() 18 | 19 | const markedElements = container.querySelectorAll("mark") 20 | 21 | // There are three "a"s in the test text and they should all be marked 22 | expect(markedElements).toHaveLength(3) 23 | markedElements.forEach((element) => { 24 | expect(element).toHaveTextContent(search) 25 | }) 26 | }) 27 | 28 | it("should mark the searched text within the dom for a whole phrase (case-insensetive)", () => { 29 | const search = "Brown Fox" 30 | 31 | const { container } = render() 32 | 33 | const markedElements = container.querySelectorAll("mark") 34 | 35 | expect(markedElements).toHaveLength(1) 36 | markedElements.forEach((element) => { 37 | expect(element).toHaveTextContent("brown fox") 38 | }) 39 | }) 40 | 41 | it("should call done callback when mark finished", () => { 42 | const search = "Brown Fox" 43 | const done = jest.fn() 44 | 45 | render() 46 | 47 | expect(done).toHaveBeenCalledTimes(1) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/helpers/searchString.test.ts: -------------------------------------------------------------------------------- 1 | import { searchString } from './searchString' 2 | 3 | describe('searchString', () => { 4 | it('returns the match from a string with the given start/end buffer', () => { 5 | const res = searchString({ 6 | text: 'i really want to be searched', 7 | search: 'wan', 8 | buffer: 5, 9 | }) 10 | 11 | expect(res).toEqual({ 12 | match: 'wan', 13 | start: 'ally ', 14 | end: 't to ', 15 | }) 16 | }) 17 | 18 | it('handles searched when matched portion is at the start', () => { 19 | const res = searchString({ 20 | text: 'i really want to be searched', 21 | search: 'i', 22 | buffer: 5, 23 | }) 24 | 25 | expect(res).toEqual({ 26 | match: 'i', 27 | start: '', 28 | end: ' real', 29 | }) 30 | }) 31 | 32 | it('handles searched when matched portion is at the end', () => { 33 | const res = searchString({ 34 | text: 'i really want to be searched', 35 | search: 'searched', 36 | buffer: 5, 37 | }) 38 | 39 | expect(res).toEqual({ 40 | match: 'searched', 41 | start: 'o be ', 42 | end: '', 43 | }) 44 | }) 45 | 46 | it('handles searched when matched portion is not found', () => { 47 | const res = searchString({ 48 | text: 'i really want to be searched', 49 | search: 'not found', 50 | buffer: 5, 51 | }) 52 | 53 | expect(res).toEqual({ 54 | match: '', 55 | start: '', 56 | end: '', 57 | }) 58 | }) 59 | 60 | it('returns the match for special characters', () => { 61 | const res = searchString({ 62 | text: 'hello world (and universe)', 63 | search: '(a', 64 | buffer: 5, 65 | }) 66 | 67 | expect(res).toEqual({ 68 | match: '(a', 69 | start: 'orld ', 70 | end: 'nd un', 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/hooks/useHighlight/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | import hljs from "highlight.js" 3 | 4 | type Language = "json" | "graphql" 5 | 6 | export interface MessagePayload { 7 | language: Language 8 | code: string 9 | } 10 | 11 | const createWorker = () => { 12 | try { 13 | return new Worker(new URL("./worker.ts", import.meta.url)) 14 | } catch (e) { 15 | return undefined 16 | } 17 | } 18 | 19 | /** 20 | * Highlight the text in a worker thread and return the resulting markup. 21 | * This provides a performant async way to render the given text. 22 | * 23 | * @param language the language to highlight against 24 | * @param code the code to highlight 25 | * @returns 26 | */ 27 | export const useHighlight = (language: Language, code: string) => { 28 | const [loading, setLoading] = useState(false) 29 | const [markup, setMarkup] = useState("") 30 | 31 | useEffect(() => { 32 | const highlightOnMainThread = () => { 33 | const result = hljs.highlight(code, { language }) 34 | setMarkup(result.value) 35 | setLoading(false) 36 | } 37 | 38 | // Highlight small code blocks in the main thread 39 | if (code.length < 500) { 40 | highlightOnMainThread() 41 | return 42 | } 43 | 44 | // Highlight large code blocks in a worker thread 45 | const worker = createWorker() 46 | if (!worker) { 47 | highlightOnMainThread() 48 | return 49 | } 50 | 51 | worker.onmessage = (event) => { 52 | setLoading(false) 53 | setMarkup(event.data) 54 | } 55 | 56 | setLoading(true) 57 | const messagePayload: MessagePayload = { language, code } 58 | worker.postMessage(messagePayload) 59 | 60 | return () => { 61 | worker.terminate() 62 | } 63 | }, [setLoading, setMarkup, language, code]) 64 | 65 | return { markup, loading } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Tabs/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import cx from "classnames" 3 | import { Header } from "../Header" 4 | 5 | export type ITab = { 6 | id?: string 7 | title: string 8 | component: ReactNode 9 | bottomComponent?: ReactNode 10 | } 11 | 12 | export interface ITabsProps { 13 | tabs: ITab[] 14 | leftContent?: ReactNode 15 | rightContent?: ReactNode 16 | activeTab: number 17 | onTabClick: (activeTab: number) => void 18 | testId?: string 19 | } 20 | 21 | export const Tabs = (props: ITabsProps) => { 22 | const { tabs, leftContent, rightContent, activeTab, onTabClick, testId } = 23 | props 24 | 25 | return ( 26 |
27 |
28 | {tabs.map((tab, i) => { 29 | const isActive = i === activeTab 30 | return ( 31 | 47 | ) 48 | })} 49 |
50 |
51 | {tabs[activeTab]?.component} 52 |
53 | {tabs[activeTab]?.bottomComponent && ( 54 |
55 | {tabs[activeTab]?.bottomComponent} 56 |
57 | )} 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/useUserSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks' 2 | import useUserSettings from './useUserSettings' 3 | import * as userSettingsService from '../services/userSettingsService' 4 | 5 | // Mock the userSettingsService module 6 | jest.mock('../services/userSettingsService') 7 | 8 | const mockUserSettings = userSettingsService as jest.Mocked< 9 | typeof userSettingsService 10 | > 11 | 12 | describe('useUserSettings', () => { 13 | it('should initialize with default settings and allow updates', async () => { 14 | mockUserSettings.getUserSettings.mockImplementation((cb) => { 15 | cb({ isPreserveLogsActive: true }) 16 | }) 17 | 18 | const { result } = renderHook(() => useUserSettings()) 19 | 20 | // Expect initial settings to be loaded into state 21 | expect(result.current[0]).toEqual({ 22 | isPreserveLogsActive: true, 23 | isInvertFilterActive: false, 24 | isRegexActive: false, 25 | filter: '', 26 | websocketUrlFilter: '', 27 | shouldShowFullWebsocketMessage: true, 28 | }) 29 | 30 | // Update the state 31 | act(() => { 32 | result.current[1]({ 33 | isPreserveLogsActive: false, 34 | isInvertFilterActive: true, 35 | }) 36 | }) 37 | 38 | // Expect state to be updated 39 | expect(result.current[0]).toEqual({ 40 | isPreserveLogsActive: false, 41 | isInvertFilterActive: true, 42 | isRegexActive: false, 43 | filter: '', 44 | websocketUrlFilter: '', 45 | shouldShowFullWebsocketMessage: true, 46 | }) 47 | 48 | // Expect setUserSettings was called with the new settings 49 | expect(userSettingsService.setUserSettings).toHaveBeenCalledWith({ 50 | isPreserveLogsActive: false, 51 | isInvertFilterActive: true, 52 | isRegexActive: false, 53 | filter: '', 54 | websocketUrlFilter: '', 55 | shouldShowFullWebsocketMessage: true, 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/hooks/useMaintainScrollBottom.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef, useState } from "react" 2 | import { usePrevious } from "./usePrevious" 3 | 4 | interface IUseMaintainScrollBottomArgs { 5 | isActive?: boolean 6 | totalEntries: number 7 | } 8 | 9 | const isScrollBottom = ( 10 | scrollableElement: HTMLElement, 11 | thresholdPixels: number 12 | ) => { 13 | const { scrollHeight, clientHeight, scrollTop } = scrollableElement 14 | return ( 15 | scrollTop > 0 && 16 | Math.ceil(scrollTop + clientHeight) >= scrollHeight - thresholdPixels 17 | ) 18 | } 19 | 20 | const isScrollable = (scrollableElement: HTMLElement) => { 21 | const { scrollHeight, clientHeight } = scrollableElement 22 | return scrollHeight > clientHeight 23 | } 24 | 25 | export const useMaintainScrollBottom = (args: IUseMaintainScrollBottomArgs) => { 26 | const { totalEntries, isActive } = args 27 | const previousTotalEntried = usePrevious(totalEntries) 28 | const scrollRef = useRef(null) 29 | const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true) 30 | const isEntriesChanged = previousTotalEntried !== totalEntries 31 | 32 | useEffect(() => { 33 | const scrollElement = scrollRef.current 34 | if (!scrollElement) { 35 | return 36 | } 37 | 38 | const handleScroll = () => { 39 | if (isScrollable(scrollElement)) { 40 | setIsAutoScrollEnabled(isScrollBottom(scrollElement, 50)) 41 | } 42 | } 43 | 44 | scrollElement.addEventListener("scroll", handleScroll) 45 | return () => scrollElement.removeEventListener("scroll", handleScroll) 46 | }, [scrollRef, setIsAutoScrollEnabled]) 47 | 48 | useLayoutEffect(() => { 49 | const scrollElement = scrollRef.current 50 | if (isAutoScrollEnabled && isEntriesChanged && isActive && scrollElement) { 51 | scrollElement.scrollTop = scrollElement.scrollHeight 52 | } 53 | }, [scrollRef, isAutoScrollEnabled, isEntriesChanged, isActive]) 54 | 55 | return scrollRef 56 | } 57 | -------------------------------------------------------------------------------- /src/services/searchService.ts: -------------------------------------------------------------------------------- 1 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 2 | import { 3 | getHeaderSearchContent, 4 | getRequestSearchContent, 5 | getResponseSearchContent, 6 | } from '@/helpers/getSearchContent' 7 | 8 | export interface ISearchResult { 9 | matches: { 10 | request: boolean 11 | response: boolean 12 | headers: boolean 13 | } 14 | networkRequest: ICompleteNetworkRequest 15 | } 16 | 17 | const getMatchedHeaders = ( 18 | searchQuery: string, 19 | networkRequests: ICompleteNetworkRequest 20 | ): boolean => { 21 | return getHeaderSearchContent(networkRequests) 22 | .toLowerCase() 23 | .includes(searchQuery) 24 | } 25 | 26 | const getMatchedRequest = ( 27 | searchQuery: string, 28 | networkRequests: ICompleteNetworkRequest 29 | ): boolean => { 30 | return getRequestSearchContent(networkRequests) 31 | .toLowerCase() 32 | .includes(searchQuery) 33 | } 34 | 35 | const getMatchedResponse = ( 36 | searchQuery: string, 37 | networkRequests: ICompleteNetworkRequest 38 | ): boolean => { 39 | return getResponseSearchContent(networkRequests) 40 | .toLowerCase() 41 | .includes(searchQuery) 42 | } 43 | 44 | export const getSearchResults = ( 45 | searchQuery: string, 46 | networkRequests: ICompleteNetworkRequest[] 47 | ): ISearchResult[] => { 48 | if (!searchQuery) { 49 | return [] 50 | } 51 | 52 | const lowercaseSearchQuery = searchQuery.toLocaleLowerCase() 53 | return networkRequests 54 | .map((networkRequest) => { 55 | const matches: ISearchResult['matches'] = { 56 | request: getMatchedRequest(lowercaseSearchQuery, networkRequest), 57 | response: getMatchedResponse(lowercaseSearchQuery, networkRequest), 58 | headers: getMatchedHeaders(lowercaseSearchQuery, networkRequest), 59 | } 60 | 61 | return { 62 | networkRequest, 63 | matches, 64 | } 65 | }) 66 | .filter((searchResult) => { 67 | return Object.values(searchResult.matches).some(Boolean) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /src/services/networkMonitor.ts: -------------------------------------------------------------------------------- 1 | import { chromeProvider } from './chromeProvider' 2 | 3 | export const getHAR = async (): Promise => { 4 | const chrome = chromeProvider() 5 | return new Promise((resolve) => { 6 | chrome.devtools.network.getHAR((harLog) => { 7 | resolve(harLog) 8 | }) 9 | }) 10 | } 11 | 12 | export const onBeforeRequest = ( 13 | cb: (e: chrome.webRequest.WebRequestBodyDetails) => void 14 | ) => { 15 | const chrome = chromeProvider() 16 | const currentTabId = chrome.devtools.inspectedWindow.tabId 17 | 18 | chrome.webRequest.onBeforeRequest.addListener( 19 | cb, 20 | { urls: [''], tabId: currentTabId }, 21 | ['requestBody'] 22 | ) 23 | return () => { 24 | chrome.webRequest.onBeforeRequest.removeListener(cb) 25 | } 26 | } 27 | 28 | export const onBeforeSendHeaders = ( 29 | cb: (e: chrome.webRequest.WebRequestHeadersDetails) => void 30 | ) => { 31 | const chrome = chromeProvider() 32 | const currentTabId = chrome.devtools.inspectedWindow.tabId 33 | 34 | chrome.webRequest.onBeforeSendHeaders.addListener( 35 | cb, 36 | { urls: [''], tabId: currentTabId }, 37 | ['requestHeaders'] 38 | ) 39 | return () => { 40 | chrome.webRequest.onBeforeSendHeaders.removeListener(cb) 41 | } 42 | } 43 | 44 | export const onRequestFinished = ( 45 | cb: (e: chrome.devtools.network.Request) => void 46 | ) => { 47 | const chrome = chromeProvider() 48 | 49 | chrome.devtools.network.onRequestFinished.addListener(cb) 50 | return () => { 51 | chrome.devtools.network.onRequestFinished.removeListener(cb) 52 | } 53 | } 54 | 55 | export const onNavigate = (cb: () => void) => { 56 | const chrome = chromeProvider() 57 | chrome.devtools.network.onNavigated.addListener(cb) 58 | return () => { 59 | chrome.devtools.network.onNavigated.removeListener(cb) 60 | } 61 | } 62 | 63 | // Can I get request body in webrequest event? 64 | // If so, can just match on headers, method, body. 65 | // Just associate first response where match, is found. 66 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/WebSocketNetworkDetails/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { CloseButton } from "../../../components/CloseButton" 3 | import { Tabs } from "../../../components/Tabs" 4 | import { NetworkTabs, useNetworkTabs } from "../../../hooks/useNetworkTabs" 5 | import { IWebSocketNetworkRequest } from "../../../hooks/useWebSocketNetworkMonitor" 6 | import { HeaderView } from "../HeaderView" 7 | import MessageView from "./MessageView" 8 | 9 | interface WebSocketNetworkDetailsProps { 10 | data: IWebSocketNetworkRequest 11 | onClose: () => void 12 | showFullMessage: boolean 13 | } 14 | 15 | const WebSocketNetworkDetails = (props: WebSocketNetworkDetailsProps) => { 16 | const { data, onClose, showFullMessage } = props 17 | const { activeTab, setActiveTab } = useNetworkTabs() 18 | const requestHeaders = data.request.headers 19 | const responseHeaders = data.response?.headers || [] 20 | 21 | // Ensure we reset tab position if out of range for 22 | // websocket view. 23 | useEffect(() => { 24 | if (activeTab !== NetworkTabs.HEADER && activeTab !== NetworkTabs.REQUEST) { 25 | setActiveTab(NetworkTabs.REQUEST) 26 | } 27 | }, [activeTab, setActiveTab]) 28 | 29 | return ( 30 | 36 | 37 | 38 | } 39 | tabs={[ 40 | { 41 | id: "headers", 42 | title: "Headers", 43 | component: ( 44 | 48 | ), 49 | }, 50 | { 51 | id: "messages", 52 | title: "Messages", 53 | component: , 54 | }, 55 | ]} 56 | /> 57 | ) 58 | } 59 | 60 | export default WebSocketNetworkDetails 61 | -------------------------------------------------------------------------------- /src/components/Table/Table.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent } from "@testing-library/react" 2 | import { render } from "../../test-utils" 3 | import { Table, ITableProps } from "./index" 4 | 5 | const data = [ 6 | { 7 | id: 1, 8 | title: "Batman Begins", 9 | year: 2005, 10 | rating: 4, 11 | }, 12 | { 13 | id: 2, 14 | title: "Get Out", 15 | year: 2017, 16 | rating: 5, 17 | }, 18 | { 19 | id: 3, 20 | title: "George of the Jungle", 21 | year: 1997, 22 | rating: 3, 23 | }, 24 | ] 25 | 26 | const columns: ITableProps["columns"] = [ 27 | { 28 | accessor: "title", 29 | Header: "Title", 30 | }, 31 | { 32 | accessor: "year", 33 | Header: "Year", 34 | }, 35 | { 36 | accessor: "rating", 37 | Header: "Rating", 38 | }, 39 | ] 40 | 41 | describe("Table", () => { 42 | it("outputs correct row index and data when a row is clicked", () => { 43 | const mockOnRowClick = jest.fn() 44 | const { getByText } = render( 45 | 46 | ) 47 | 48 | fireEvent.click(getByText(/Get Out/i)) 49 | 50 | expect(mockOnRowClick).toHaveBeenCalledWith(2, { 51 | id: 2, 52 | title: "Get Out", 53 | year: 2017, 54 | rating: 5, 55 | }) 56 | }) 57 | 58 | it("data is empty - empty table message is rendered", () => { 59 | const { getByText } = render(
) 60 | 61 | // ensure the empty table message was rendered 62 | expect(getByText("No requests have been detected")).toBeInTheDocument() 63 | }) 64 | 65 | it("data is empty and an error message is provided - error message is rendered", () => { 66 | const { getByText, queryByText } = render( 67 |
68 | ) 69 | 70 | // ensure the empty table message was not rendered 71 | expect( 72 | queryByText("No requests have been detected") 73 | ).not.toBeInTheDocument() 74 | 75 | // ensure the error message was rendered 76 | expect(getByText("someErrorMessage")).toBeInTheDocument() 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/containers/SearchPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 3 | import { useSearch } from '@/hooks/useSearch' 4 | import { NetworkTabs } from '@/hooks/useNetworkTabs' 5 | import { getSearchResults, ISearchResult } from '@/services/searchService' 6 | import { SearchResults } from './SearchResults' 7 | import { Header } from '@/components/Header' 8 | import { CloseButton } from '@/components/CloseButton' 9 | import { SearchInput } from '@/components/SearchInput' 10 | import { IWebSocketNetworkRequest } from '@/hooks/useWebSocketNetworkMonitor' 11 | 12 | interface ISearchPanelProps { 13 | networkRequests: ICompleteNetworkRequest[] 14 | webSocketNetworkRequests: IWebSocketNetworkRequest[] 15 | onResultClick: ( 16 | searchResult: ISearchResult, 17 | searchResultType: NetworkTabs 18 | ) => void 19 | } 20 | 21 | export const SearchPanel = (props: ISearchPanelProps) => { 22 | const { networkRequests, onResultClick } = props 23 | const { searchQuery, setSearchQuery, setIsSearchOpen } = useSearch() 24 | const searchResults = useMemo( 25 | () => getSearchResults(searchQuery, networkRequests), 26 | [searchQuery, networkRequests] 27 | ) 28 | 29 | return ( 30 |
34 |
setIsSearchOpen(false)} 38 | className="mr-2" 39 | /> 40 | } 41 | > 42 |
43 |

Search

44 |
45 |
46 |
47 | 48 |
49 | {searchResults && ( 50 |
51 | 56 |
57 | )} 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/CopyAsCurlButton/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from "@testing-library/react" 2 | import { CopyAsCurlButton } from "./" 3 | import { useCopyCurl } from "@/hooks/useCopyCurl/useCopyCurl" 4 | import { ICompleteNetworkRequest } from "@/helpers/networkHelpers" 5 | 6 | jest.mock("@/hooks/useCopyCurl/useCopyCurl") 7 | 8 | describe("CopyAsCurlButton", () => { 9 | const mockCopyAsCurl = jest.fn() 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks() 13 | ;(useCopyCurl as jest.Mock).mockReturnValue({ 14 | copyAsCurl: mockCopyAsCurl, 15 | isCopied: false 16 | }) 17 | }) 18 | 19 | it("renders with correct label", () => { 20 | const { getByTestId } = render() 21 | expect(getByTestId("copy-button")).toHaveTextContent("Copy as cURL") 22 | }) 23 | 24 | it("calls copyAsCurl when clicked with network request", () => { 25 | const mockRequest = { 26 | id: '123', 27 | url: 'https://example.com', 28 | method: 'GET', 29 | status: 200, 30 | time: new Date().getTime(), 31 | request: { 32 | primaryOperation: { 33 | operationName: 'TestQuery', 34 | operation: 'query' 35 | }, 36 | headers: [], 37 | headersSize: 100, 38 | body: [], 39 | bodySize: 0 40 | }, 41 | native: { 42 | webRequest: { 43 | requestId: '123', 44 | url: 'https://example.com', 45 | method: 'GET', 46 | frameId: 0, 47 | parentFrameId: -1, 48 | tabId: -1, 49 | type: 'xmlhttprequest', 50 | timeStamp: new Date().getTime(), 51 | requestBody: null 52 | } 53 | } 54 | } as ICompleteNetworkRequest 55 | const { getByTestId } = render() 56 | 57 | fireEvent.click(getByTestId("copy-button")) 58 | expect(mockCopyAsCurl).toHaveBeenCalledWith(mockRequest) 59 | }) 60 | 61 | it("doesn't call copyAsCurl when clicked without network request", () => { 62 | const { getByTestId } = render() 63 | 64 | fireEvent.click(getByTestId("copy-button")) 65 | expect(mockCopyAsCurl).not.toHaveBeenCalled() 66 | }) 67 | }) -------------------------------------------------------------------------------- /src/containers/NetworkPanel/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react' 2 | import { NetworkPanel } from './index' 3 | import { render } from '../../test-utils' 4 | import useUserSettings from '@/hooks/useUserSettings' 5 | 6 | jest.mock('@/hooks/useHighlight', () => ({ 7 | useHighlight: () => ({ 8 | markup: '
hi
', 9 | loading: false, 10 | }), 11 | })) 12 | 13 | jest.mock('@/services/userSettingsService', () => ({ 14 | getUserSettings: jest.fn(), 15 | setUserSettings: jest.fn(), 16 | })) 17 | 18 | const NetworkPanelContainer = () => { 19 | const [userSettings, setUserSettings] = useUserSettings(); 20 | 21 | return { }} 26 | networkRequests={[]} 27 | webSocketNetworkRequests={[]} 28 | clearWebRequests={() => { }} 29 | /> 30 | } 31 | 32 | describe('NetworkPanel', () => { 33 | it('invalid regex is provided, regex mode is on - error message is rendered', () => { 34 | const { getByTestId, getByText } = render( 35 | 36 | ) 37 | const filterInput = getByTestId('filter-input') 38 | const regexCheckbox = getByTestId('regex-checkbox') 39 | 40 | // click the regex checkbox to turn the regex mode on 41 | fireEvent.click(regexCheckbox) 42 | 43 | // enter an invalid regex into the filter input 44 | fireEvent.change(filterInput, { target: { value: '++' } }) 45 | 46 | // ensure the error message related to the invalid regex was rendered 47 | expect( 48 | getByText('Invalid regular expression: /++/: Nothing to repeat') 49 | ).toBeInTheDocument() 50 | }) 51 | 52 | it('invalid regex is provided, regex mode is off - error message is not rendered', () => { 53 | const { getByTestId, queryByText } = render( 54 | 55 | ) 56 | const filterInput = getByTestId('filter-input') 57 | 58 | // enter an invalid regex into the filter input 59 | fireEvent.change(filterInput, { target: { value: '++' } }) 60 | 61 | // ensure the error message related to the invalid regex was not rendered 62 | expect( 63 | queryByText('Invalid regular expression: /++/: Nothing to repeat') 64 | ).not.toBeInTheDocument() 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | [data-color-scheme="dark"] { 6 | color-scheme: dark; 7 | } 8 | 9 | [data-color-scheme="light"] { 10 | color-scheme: light; 11 | } 12 | 13 | html { 14 | font-size: 10px; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | font-size: 1.2rem; 20 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 21 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 22 | sans-serif; 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | code { 28 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 29 | monospace; 30 | } 31 | 32 | h1 { 33 | font-size: 1.6rem; 34 | } 35 | 36 | h2 { 37 | font-size: 1.2rem; 38 | } 39 | 40 | input { 41 | font-size: 1.2rem !important; 42 | } 43 | 44 | button { 45 | cursor: pointer; 46 | } 47 | 48 | /* Scroll */ 49 | 50 | .scroll { 51 | -ms-overflow-style: none; 52 | overflow: -moz-scrollbars-none; 53 | } 54 | .scroll::-webkit-scrollbar { 55 | width: 0 !important; 56 | } 57 | 58 | /* Resizer */ 59 | 60 | .Resizer { 61 | opacity: 0.5; 62 | z-index: 1; 63 | -moz-box-sizing: border-box; 64 | -webkit-box-sizing: border-box; 65 | box-sizing: border-box; 66 | -moz-background-clip: padding; 67 | -webkit-background-clip: padding; 68 | background-clip: padding-box; 69 | z-index: 20; 70 | } 71 | 72 | .Resizer.vertical { 73 | width: 11px; 74 | margin: 0 -5px; 75 | border-left: 5px solid rgba(255, 255, 255, 0); 76 | border-right: 5px solid rgba(255, 255, 255, 0); 77 | cursor: col-resize; 78 | transition: 0.1s; 79 | } 80 | .Resizer.vertical:hover { 81 | border-left: 5px solid rgba(255, 255, 255, 0.2); 82 | border-right: 5px solid rgba(255, 255, 255, 0.2); 83 | } 84 | 85 | .Resizer.disabled { 86 | cursor: not-allowed; 87 | } 88 | .Resizer.disabled:hover { 89 | border-color: transparent; 90 | } 91 | 92 | .Pane2 { 93 | width: 0; 94 | } 95 | 96 | /* JSON Viewer */ 97 | 98 | .react-json-view { 99 | background: none !important; 100 | } 101 | 102 | .react-json-view *:focus { 103 | border-radius: 3px; 104 | outline: 2px solid #d4d4d4; 105 | background-color: #d4d4d4; 106 | } 107 | 108 | .dark .react-json-view *:focus { 109 | outline: 2px solid #3a4351; 110 | background-color: #3a4351; 111 | } 112 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 28 | 29 | 33 | 39 | 45 | React App 46 | 47 | 48 | 49 |
50 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/WebSocketNetworkDetails/MessageView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { CodeView } from "../../../components/CodeView" 3 | import { CopyButton } from "../../../components/CopyButton" 4 | import { IWebSocketMessage } from "../../../hooks/useWebSocketNetworkMonitor" 5 | import { PanelSection, Panels } from "../PanelSection" 6 | import * as safeJson from "@/helpers/safeJson" 7 | 8 | interface IMessageViewProps { 9 | messages: IWebSocketMessage[] 10 | showFullMessage: boolean 11 | } 12 | 13 | /** 14 | * Returns the time the message was sent in a human readable format 15 | * 16 | * @param time time in milliseconds 17 | * @returns 18 | */ 19 | const getReadableTime = (time: number): string => { 20 | const date = new Date(time * 1000) 21 | return date.toLocaleTimeString() 22 | } 23 | 24 | const MessageView = React.memo((props: IMessageViewProps) => { 25 | const { messages, showFullMessage } = props 26 | 27 | return ( 28 | 29 | {messages.map((message, i) => { 30 | const payload = JSON.stringify(showFullMessage ? message.data : message.data.payload, null, 2) 31 | const isGraphQLQuery = message.type === "send" && message.data?.query 32 | 33 | return ( 34 | 35 | 39 |
40 | {getReadableTime(message.time)} | {message.type} 41 |
42 | 43 | {isGraphQLQuery ? ( 44 |
45 | 50 | {message.data.variables && ( 51 | 60 | )} 61 |
62 | ) : ( 63 | 64 | )} 65 |
66 | ) 67 | })} 68 |
69 | ) 70 | }) 71 | 72 | export default MessageView 73 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/HeaderView/HeaderList.tsx: -------------------------------------------------------------------------------- 1 | import { useMarkSearch } from "@/hooks/useMark" 2 | import useCopy from "@/hooks/useCopy" 3 | import parseAuthHeader from "./parseAuthHeader" 4 | import { useState } from "react" 5 | import { IHeader } from "../../../helpers/networkHelpers" 6 | 7 | interface IHeadersProps { 8 | headers: IHeader[] 9 | } 10 | 11 | const HeaderListItem = (props: { header: IHeader }) => { 12 | const { header } = props 13 | const { isCopied, copy } = useCopy() 14 | const [parsedValue, setParsedValue] = useState(null) 15 | 16 | /** 17 | * Copy the header value as-is to the clipboard 18 | * 19 | */ 20 | const copyHeader = (header: IHeader) => { 21 | copy(`"${header.name}": "${header.value}"`) 22 | } 23 | 24 | /** 25 | * To automatically parse JWTs the user can double click. 26 | * 27 | * If the header is a valid JWT the value will be parsed 28 | * and copied. Double clicking again will reverse this action. 29 | * 30 | */ 31 | const parseAndCopyHeader = (header: IHeader) => { 32 | if (!header.value) { 33 | return 34 | } 35 | 36 | // If value already parsed, double click will clear it. 37 | if (parsedValue) { 38 | setParsedValue(null) 39 | copy(header.value) 40 | return 41 | } 42 | 43 | // Parse the header and copy the parsed value 44 | const parsed = parseAuthHeader(header) 45 | if (parsed) { 46 | setParsedValue(parsed) 47 | copy(parsed) 48 | } 49 | } 50 | 51 | return ( 52 |
  • 53 | 61 | {isCopied && ( 62 |
    63 | Copied! 64 |
    65 | )} 66 |
  • 67 | ) 68 | } 69 | 70 | export const HeaderList = (props: IHeadersProps) => { 71 | const { headers } = props 72 | const ref = useMarkSearch() 73 | 74 | return ( 75 |
      76 | {headers.map((header) => ( 77 | 81 | ))} 82 |
    83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/containers/Main/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | import { SplitPaneLayout } from '@/components/Layout' 3 | import { 4 | IClearWebRequestsOptions, 5 | useNetworkMonitor, 6 | } from '@/hooks/useNetworkMonitor' 7 | import { useSearch } from '@/hooks/useSearch' 8 | import { useNetworkTabs } from '@/hooks/useNetworkTabs' 9 | import { NetworkPanel } from '../NetworkPanel' 10 | import { SearchPanel } from '../SearchPanel' 11 | import { useWebSocketNetworkMonitor } from '../../hooks/useWebSocketNetworkMonitor' 12 | import { useOperationFilters } from '../../hooks/useOperationFilters' 13 | import useUserSettings from '../../hooks/useUserSettings' 14 | import VersionNumber from '../../components/VersionNumber' 15 | 16 | export const Main = () => { 17 | const [selectedRowId, setSelectedRowId] = useState( 18 | null 19 | ) 20 | const { operationFilters } = useOperationFilters() 21 | const [userSettings, setUserSettings] = useUserSettings() 22 | const [networkRequests, clearWebRequests] = useNetworkMonitor() 23 | const [webSocketNetworkRequests, clearWebSocketNetworkRequests] = 24 | useWebSocketNetworkMonitor({ 25 | isEnabled: operationFilters.subscription, 26 | urlFilter: userSettings.websocketUrlFilter, 27 | }) 28 | const { isSearchOpen } = useSearch() 29 | const { setActiveTab } = useNetworkTabs() 30 | 31 | const clearRequests = useCallback( 32 | (opts?: IClearWebRequestsOptions) => { 33 | clearWebRequests(opts) 34 | clearWebSocketNetworkRequests() 35 | }, 36 | [clearWebRequests, clearWebSocketNetworkRequests] 37 | ) 38 | 39 | return ( 40 | <> 41 | 42 | { 49 | setSelectedRowId(searchResult.networkRequest.id) 50 | setActiveTab(networkTab) 51 | }} 52 | /> 53 | ) : undefined 54 | } 55 | rightPane={ 56 | 65 | } 66 | /> 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button } from '../Button' 3 | 4 | interface ErrorInfoProps { 5 | error: Error 6 | onReload: () => void 7 | } 8 | 9 | const GitHubIssueLink: React.FC = (props) => { 10 | const { error, onReload } = props 11 | 12 | const title = encodeURIComponent(`Bug report: ${error.message}`) 13 | const body = encodeURIComponent(`I encountered an error: ${error.message}`) 14 | const githubUrl = `https://github.com/warrenday/graphql-network-inspector/issues/new?title=${title}&body=${body}` 15 | 16 | return ( 17 |
    18 |
    19 |
    Something went wrong:
    20 |
    21 | {error.message} 22 | {error.stack &&
    {error.stack}
    } 23 |
    24 |
    25 |
    26 | To help us debug the issue, please report on github and include any 27 | relevant stack traces or print screens. 28 |
    29 | 30 | 33 | 34 | 40 |
    41 |
    42 |
    43 | ) 44 | } 45 | 46 | interface IErrorBoundaryState { 47 | hasError: boolean 48 | error: Error | null 49 | } 50 | 51 | export class ErrorBoundary extends React.Component { 52 | state: IErrorBoundaryState = { hasError: false, error: null } 53 | 54 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 55 | console.error('Error caught by ErrorBoundary:', error) 56 | console.error('Error details:', errorInfo) 57 | } 58 | 59 | static getDerivedStateFromError(error: Error) { 60 | return { hasError: true, error } 61 | } 62 | 63 | render() { 64 | if (this.state.hasError && this.state.error) { 65 | return ( 66 | { 69 | this.setState({ hasError: false, error: null }) 70 | }} 71 | /> 72 | ) 73 | } 74 | 75 | // Normally, just render children 76 | return this.props.children 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/hooks/useCopyCurl/useCopyCurl.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks' 2 | import { getNetworkCurl } from '@/helpers/curlHelpers' 3 | import useCopy from '../useCopy' 4 | import { useCopyCurl } from './useCopyCurl' 5 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 6 | 7 | // Mock dependencies 8 | jest.mock('@/helpers/curlHelpers') 9 | jest.mock('../useCopy') 10 | 11 | const mockNetworkRequest: ICompleteNetworkRequest = { 12 | id: '123', 13 | url: 'https://example.com', 14 | method: 'GET', 15 | status: 200, 16 | time: new Date().getTime(), 17 | request: { 18 | primaryOperation: { 19 | operationName: 'TestQuery', 20 | operation: 'query', 21 | }, 22 | headers: [], 23 | headersSize: 0, 24 | body: [], 25 | bodySize: 0, 26 | }, 27 | native: { 28 | networkRequest: { 29 | request: { 30 | url: 'https://example.com', 31 | method: 'GET', 32 | headers: [], 33 | }, 34 | getContent: () => {}, 35 | startedDateTime: new Date(), 36 | time: 0, 37 | response: {}, 38 | _resourceType: 'fetch', 39 | cache: {}, 40 | timings: {}, 41 | } as unknown as chrome.devtools.network.Request, 42 | }, 43 | } 44 | 45 | describe('useCopyCurl', () => { 46 | const mockCopy = jest.fn() 47 | const mockIsCopied = false 48 | 49 | beforeEach(() => { 50 | jest.clearAllMocks() 51 | ;(useCopy as jest.Mock).mockReturnValue({ 52 | copy: mockCopy, 53 | isCopied: mockIsCopied, 54 | }) 55 | ;(getNetworkCurl as jest.Mock).mockResolvedValue('curl example.com') 56 | }) 57 | 58 | it('should handle null network request gracefully', async () => { 59 | const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() 60 | const { result } = renderHook(() => useCopyCurl()) 61 | 62 | await act(async () => { 63 | await result.current.copyAsCurl(null as any) 64 | }) 65 | 66 | expect(consoleSpy).toHaveBeenCalledWith('No network request data available') 67 | expect(getNetworkCurl).not.toHaveBeenCalled() 68 | expect(mockCopy).not.toHaveBeenCalled() 69 | 70 | consoleSpy.mockRestore() 71 | }) 72 | 73 | it('should expose isCopied from useCopy hook', () => { 74 | const { result } = renderHook(() => useCopyCurl()) 75 | expect(result.current.isCopied).toBe(mockIsCopied) 76 | }) 77 | 78 | it('should copy curl command when network request is provided', async () => { 79 | const { result } = renderHook(() => useCopyCurl()) 80 | 81 | await act(async () => { 82 | await result.current.copyAsCurl(mockNetworkRequest) 83 | }) 84 | 85 | expect(getNetworkCurl).toHaveBeenCalledWith(mockNetworkRequest) 86 | expect(mockCopy).toHaveBeenCalledWith('curl example.com') 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/QuickFiltersContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/Button' 2 | import { OperationType } from '@/helpers/graphqlHelpers' 3 | import { Bar } from '../../../components/Bar' 4 | import theme from '../../../theme' 5 | import { useOperationFilters } from '../../../hooks/useOperationFilters' 6 | 7 | interface IPillProps { 8 | className: string 9 | } 10 | 11 | const Pill = (props: IPillProps) => { 12 | const { className } = props 13 | return
    14 | } 15 | 16 | interface IQuickFilterButtonProps { 17 | variant: 'primary' | 'ghost' 18 | onClick: () => void 19 | active: boolean 20 | activeColor: string 21 | children: React.ReactNode 22 | } 23 | 24 | const QuickFilterButton = (props: IQuickFilterButtonProps) => { 25 | const { children, variant, onClick, active, activeColor } = props 26 | 27 | return ( 28 | 36 | ) 37 | } 38 | 39 | export const QuickFiltersContainer = () => { 40 | const { operationFilters, setOperationFilters } = useOperationFilters() 41 | 42 | const handleQuickFilterToggle = (operationType: OperationType) => { 43 | setOperationFilters((prevState) => { 44 | return { 45 | ...prevState, 46 | [operationType]: !prevState[operationType], 47 | } 48 | }) 49 | } 50 | 51 | return ( 52 | 53 |
    54 | handleQuickFilterToggle('query')} 57 | active={operationFilters.query} 58 | activeColor={theme.operationColors.query.bg} 59 | > 60 | Queries 61 | 62 | handleQuickFilterToggle('mutation')} 65 | active={operationFilters.mutation} 66 | activeColor={theme.operationColors.mutation.bg} 67 | > 68 | Mutations 69 | 70 | handleQuickFilterToggle('persisted')} 73 | active={operationFilters.persisted} 74 | activeColor={theme.operationColors.persisted.bg} 75 | > 76 | Persisted 77 | 78 | handleQuickFilterToggle('subscription')} 81 | active={operationFilters.subscription} 82 | activeColor={theme.operationColors.subscription.bg} 83 | > 84 | Subscriptions 85 | 86 |
    87 |
    88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/helpers/curlHelpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats request body with proper escaping and unicode handling 3 | * @param text - Raw request body text 4 | * @returns Formatted body string with proper escaping 5 | */ 6 | const formatBody = (text: string): string => { 7 | // Handle multipart form data 8 | if (text.includes('Content-Type: multipart/form-data')) { 9 | return text 10 | .split('\r\n') 11 | .map((line) => line.replace(/'/g, "'\\''")) 12 | .join('\\r\\n') 13 | } 14 | 15 | // Handle binary data using character codes 16 | const hasBinaryData = Array.from(text).some((char) => { 17 | const code = char.charCodeAt(0) 18 | return code <= 8 || (code >= 14 && code <= 31) 19 | }) 20 | 21 | if (hasBinaryData) { 22 | return Buffer.from(text).toString('base64') 23 | } 24 | 25 | // Normal JSON handling 26 | try { 27 | const body = JSON.parse(text) 28 | return JSON.stringify(body).replace( 29 | /[^\x20-\x7E]/g, 30 | (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` 31 | ) 32 | } catch { 33 | return text 34 | } 35 | } 36 | 37 | /** 38 | * Generates a cURL command from a network request 39 | * Matches Chrome DevTools cURL format including: 40 | * - Header filtering (excludes pseudo headers) 41 | * - Unicode escaping 42 | * - Proper quoting and line continuation 43 | * 44 | * @example 45 | * ```typescript 46 | * const curl = await getNetworkCurl(request) 47 | * // curl 'https://api.example.com/graphql' \ 48 | * // -H 'content-type: application/json' \ 49 | * // --data-raw $'{"query":"query { test }"}' 50 | * ``` 51 | */ 52 | import { ICompleteNetworkRequest } from './networkHelpers' 53 | 54 | // Headers that Chrome DevTools excludes 55 | const EXCLUDED_HEADERS = [ 56 | ':authority', 57 | ':method', 58 | ':path', 59 | ':scheme', 60 | 'content-length', 61 | 'accept-encoding', 62 | ] 63 | 64 | export const getNetworkCurl = async ( 65 | request: ICompleteNetworkRequest 66 | ): Promise => { 67 | // Use Chrome's network request data for cURL generation 68 | const chromeRequest = request?.native?.networkRequest 69 | if (!chromeRequest?.request) { 70 | console.warn('No Chrome request data available') 71 | return '' 72 | } 73 | 74 | const parts: string[] = [] 75 | 76 | // Start with curl and URL 77 | parts.push(`curl '${chromeRequest.request.url}'`) 78 | 79 | // Add headers, filtering out excluded ones 80 | const headers = chromeRequest.request.headers || [] 81 | headers 82 | .filter((header) => !EXCLUDED_HEADERS.includes(header.name.toLowerCase())) 83 | .forEach((header) => { 84 | parts.push(`-H '${header.name}: ${header.value}'`) 85 | }) 86 | 87 | // Add method if not GET 88 | if (chromeRequest.request.method !== 'GET') { 89 | parts.push(`-X ${chromeRequest.request.method}`) 90 | } 91 | 92 | // Add body with proper escaping 93 | if (chromeRequest.request.postData?.text) { 94 | const formattedBody = formatBody(chromeRequest.request.postData.text) 95 | parts.push(`--data-raw '${formattedBody}'`) 96 | } 97 | 98 | return parts.join(' \\\n ') 99 | } 100 | -------------------------------------------------------------------------------- /src/helpers/graphqlHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessages, getFirstGraphqlOperation } from './graphqlHelpers' 2 | 3 | describe('graphqlHelpers.getErrorMessages', () => { 4 | it('returns null for invalid JSON', () => { 5 | const errorMessages = getErrorMessages("{'invalid JSON'}") 6 | expect(errorMessages).toEqual(null) 7 | }) 8 | 9 | it('returns null when no body', () => { 10 | const errorMessages = getErrorMessages(undefined) 11 | expect(errorMessages).toEqual(null) 12 | }) 13 | 14 | it('returns empty array when no errors', () => { 15 | const errorMessages = getErrorMessages( 16 | JSON.stringify({ 17 | data: [], 18 | }) 19 | ) 20 | expect(errorMessages).toEqual([]) 21 | }) 22 | 23 | it('parses multiple error messages correctly', () => { 24 | const errorMessages = getErrorMessages( 25 | JSON.stringify({ 26 | errors: [{ message: 'First Error' }, { message: 'Second Error' }], 27 | }) 28 | ) 29 | expect(errorMessages).toEqual(['First Error', 'Second Error']) 30 | }) 31 | }) 32 | 33 | describe('graphqlHelpers.getFirstGraphqlOperation', () => { 34 | it('returns the operation name and type from unnamed query', () => { 35 | const operation = getFirstGraphqlOperation([ 36 | { 37 | query: 'query { field }', 38 | }, 39 | ]) 40 | expect(operation).toEqual({ 41 | operationName: 'field', 42 | operation: 'query', 43 | }) 44 | }) 45 | 46 | it('returns the operation name and type from the query', () => { 47 | const operation = getFirstGraphqlOperation([ 48 | { 49 | query: 'query MyQuery { field }', 50 | }, 51 | ]) 52 | expect(operation).toEqual({ 53 | operationName: 'MyQuery', 54 | operation: 'query', 55 | }) 56 | }) 57 | 58 | it('returns the operation name and type when operatioName is explicity provided', () => { 59 | const operation = getFirstGraphqlOperation([ 60 | { 61 | query: 'query Me { field }', 62 | operationName: 'MyQuery', 63 | }, 64 | ]) 65 | expect(operation).toEqual({ 66 | operationName: 'MyQuery', 67 | operation: 'query', 68 | }) 69 | }) 70 | 71 | it('returns the operation name for persisted queries', () => { 72 | const operation = getFirstGraphqlOperation([ 73 | { 74 | extensions: { 75 | persistedQuery: { 76 | version: 1, 77 | sha256Hash: 'hash', 78 | }, 79 | }, 80 | operationName: 'MyQuery', 81 | }, 82 | ]) 83 | expect(operation).toEqual({ 84 | operationName: 'MyQuery', 85 | operation: 'persisted', 86 | }) 87 | }) 88 | 89 | it('returns undefined if the query is invalid', () => { 90 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation() 91 | 92 | const operation = getFirstGraphqlOperation([ 93 | { 94 | query: 'invalid query', 95 | }, 96 | ]) 97 | expect(operation).toEqual(undefined) 98 | 99 | consoleSpy.mockRestore() 100 | }) 101 | 102 | it('should handle invalid query', () => { 103 | const consoleSpy = jest.spyOn(console, 'error').mockImplementation() 104 | const result = getFirstGraphqlOperation([{ query: 'invalid query' }]) 105 | expect(result).toBeUndefined() 106 | consoleSpy.mockRestore() 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /src/hooks/useShareMessage.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from 'react' 2 | import uniqid from 'uniqid' 3 | import { chromeProvider } from '../services/chromeProvider' 4 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 5 | 6 | interface IShareMessageContext { 7 | shareNetworkRequest: (networkRequest: ICompleteNetworkRequest) => void 8 | } 9 | 10 | const ShareMessageContext = createContext(null!) 11 | 12 | interface IShareMessageProviderProps { 13 | children: React.ReactNode 14 | } 15 | 16 | const prepareSharePayload = (networkRequest: ICompleteNetworkRequest) => { 17 | const responseBody = networkRequest.response?.body 18 | ? JSON.parse(networkRequest.response?.body) 19 | : {} 20 | 21 | const shareableNetworkRequest = { 22 | url: networkRequest.url, 23 | status: networkRequest.status, 24 | method: networkRequest.method, 25 | time: networkRequest.time, 26 | responseSize: networkRequest.response?.bodySize || 0, 27 | request: { 28 | headers: networkRequest.request.headers, 29 | body: networkRequest.request.body, 30 | }, 31 | response: { 32 | headers: networkRequest.response?.headers, 33 | body: responseBody, 34 | }, 35 | } 36 | 37 | return JSON.stringify(shareableNetworkRequest) 38 | } 39 | 40 | export const ShareMessageProvider = (props: IShareMessageProviderProps) => { 41 | const { children } = props 42 | 43 | // The sessionId ensures that given multiple instances of 44 | // graphql inspector, only the correct one will receive the 45 | // "ready" message. 46 | const [sessionId] = useState(uniqid()) 47 | const [payload, setPayload] = useState(null) 48 | 49 | useEffect(() => { 50 | const chrome = chromeProvider() 51 | 52 | const listener = ( 53 | request: any, 54 | sender: chrome.runtime.MessageSender, 55 | sendResponse: (response: any) => void 56 | ) => { 57 | if (request.message === 'ready' && request.sessionId === sessionId) { 58 | // Once the receiver is ready we can send the draft payload. 59 | // 60 | // Note: sendResponse will become invalid after first use. 61 | // Unless we return "true" 62 | sendResponse({ message: 'draft', payload }) 63 | } 64 | } 65 | 66 | chrome.runtime.onMessage.addListener(listener) 67 | return () => { 68 | chrome.runtime.onMessage.removeListener(listener) 69 | } 70 | }, [payload, sessionId]) 71 | 72 | const shareNetworkRequest = (networkRequest: ICompleteNetworkRequest) => { 73 | setPayload(prepareSharePayload(networkRequest)) 74 | 75 | // We start by creating a new tab. The new tab will send us 76 | // a ready message, which we are listening for above. 77 | window.open( 78 | `${process.env.REACT_APP_SHARE_TARGET_URL}/draft?sessionId=${sessionId}`, 79 | '_blank' 80 | ) 81 | } 82 | 83 | return ( 84 | 85 | {children} 86 | 87 | ) 88 | } 89 | 90 | export const useShareMessage = () => { 91 | const context = useContext(ShareMessageContext) 92 | 93 | if (!context) { 94 | throw new Error( 95 | 'useShareMessage must be used within a ShareMessageProvider' 96 | ) 97 | } 98 | 99 | return context 100 | } 101 | -------------------------------------------------------------------------------- /src/components/TracingVisualization/TracingVisualizationRow.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, CSSProperties } from "react" 2 | import { nsToMs } from "@/helpers/nsToMs" 3 | import { useBoundingRect } from "@/hooks/useBoundingRect" 4 | 5 | interface ITracingVisualizationRowProps { 6 | name?: string 7 | type?: string 8 | color?: "green" | "purple" | "indigo" 9 | total: number 10 | offset?: number 11 | duration: number 12 | style?: CSSProperties 13 | } 14 | 15 | export const TracingVisualizationRow = ( 16 | props: ITracingVisualizationRowProps 17 | ) => { 18 | const { name, total, offset, duration, type, color, style } = props 19 | 20 | const backgroundColorCss = getBackgroundColors(color || type) 21 | 22 | const { container, width } = useBoundingRect() 23 | const { 24 | marginLeftPercentage, 25 | marginLeftCss, 26 | showBeforeDuration, 27 | showAllBeforeDuration, 28 | widthPercentageCss, 29 | } = useMemo(() => { 30 | const percentage = 100 / (width || 1) 31 | const marginLeftPercentage = ((offset || 0) / total) * 100 32 | const widthPercentage = (duration / total) * 100 33 | const isAt100 = marginLeftPercentage + percentage >= 100 34 | const marginLeftCss = isAt100 35 | ? `calc(${marginLeftPercentage}% - 1px)` 36 | : `${marginLeftPercentage}%` 37 | const widthPercentageCss = 38 | widthPercentage <= percentage ? "1px" : `${widthPercentage}%` 39 | 40 | const showBeforeDuration = 41 | widthPercentage <= 50 && marginLeftPercentage >= 50 42 | const showAllBeforeDuration = 43 | showBeforeDuration && marginLeftPercentage > 90 44 | 45 | return { 46 | marginLeftPercentage, 47 | marginLeftCss, 48 | showBeforeDuration, 49 | showAllBeforeDuration, 50 | widthPercentageCss, 51 | } 52 | }, [width, duration, offset, total]) 53 | 54 | return ( 55 |
    60 | {marginLeftPercentage > 0 && ( 61 |
    65 | {showBeforeDuration && {name || ""}} 66 | 67 | {showAllBeforeDuration && ( 68 | {nsToMs(duration)} ms 69 | )} 70 |
    71 | )} 72 | 73 |
    77 |
    78 | {!showBeforeDuration && {name || ""}} 79 | 80 | {showAllBeforeDuration ? ( 81 | <>  82 | ) : ( 83 | {nsToMs(duration)} ms 84 | )} 85 |
    86 |
    87 |
    88 | ) 89 | } 90 | 91 | const getBackgroundColors = (type: string = "") => { 92 | switch (type.toUpperCase()) { 93 | case "GREEN": 94 | case "TOTAL": 95 | return "bg-green-400 dark:bg-green-700" 96 | case "PURPLE": 97 | case "QUERY": 98 | case "MUTATION": 99 | case "SUBSCRIPTION": 100 | return "bg-purple-400 dark:bg-purple-700" 101 | default: 102 | case "INDIGO": 103 | return "bg-indigo-400 dark:bg-indigo-700" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/mocks/mock-chrome.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'utility-types' 2 | import EventEmitter from 'eventemitter3' 3 | import { IMockRequest, mockRequests } from '../mocks/mock-requests' 4 | 5 | let mockStorage = {} 6 | 7 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 8 | 9 | // Configure an event to add more mock requests 10 | const eventEmitter = new EventEmitter<{ 11 | onBeforeRequest: { data: IMockRequest['webRequestBodyDetails'] } 12 | onBeforeSendHeaders: { data: IMockRequest['webRequestHeaderDetails'] } 13 | onRequestFinished: { data: IMockRequest['networkRequest'] } 14 | }>() 15 | const handleKeydown = (e: KeyboardEvent) => { 16 | if (e.code === 'Digit1') { 17 | mockRequests.forEach(async (request) => { 18 | eventEmitter.emit('onBeforeRequest', { 19 | data: request.webRequestBodyDetails, 20 | }) 21 | await wait(100) 22 | eventEmitter.emit('onBeforeSendHeaders', { 23 | data: request.webRequestHeaderDetails, 24 | }) 25 | await wait(100) 26 | eventEmitter.emit('onRequestFinished', { data: request.networkRequest }) 27 | }) 28 | } 29 | } 30 | window.addEventListener('keydown', handleKeydown) 31 | 32 | const mockedChrome: DeepPartial = { 33 | devtools: { 34 | inspectedWindow: { 35 | tabId: 1, 36 | }, 37 | panels: { 38 | themeName: 'dark', 39 | }, 40 | network: { 41 | getHAR: (cb) => { 42 | cb({ 43 | entries: mockRequests.map( 44 | (mockRequest) => mockRequest.networkRequest 45 | ), 46 | } as any) 47 | }, 48 | onRequestFinished: { 49 | addListener: (cb) => { 50 | eventEmitter.on('onRequestFinished', (event) => { 51 | cb(event.data) 52 | }) 53 | }, 54 | removeListener: () => { 55 | eventEmitter.off('onRequestFinished') 56 | }, 57 | }, 58 | onNavigated: { 59 | addListener: () => {}, 60 | removeListener: () => {}, 61 | }, 62 | }, 63 | }, 64 | webRequest: { 65 | onBeforeSendHeaders: { 66 | addListener: (cb) => { 67 | eventEmitter.on('onBeforeSendHeaders', (event) => { 68 | cb(event.data) 69 | }) 70 | }, 71 | removeListener: () => { 72 | eventEmitter.off('onBeforeSendHeaders') 73 | }, 74 | }, 75 | onBeforeRequest: { 76 | addListener: (cb) => { 77 | eventEmitter.on('onBeforeRequest', (event) => { 78 | cb(event.data) 79 | }) 80 | }, 81 | removeListener: () => { 82 | eventEmitter.off('onBeforeRequest') 83 | }, 84 | }, 85 | }, 86 | runtime: { 87 | getPlatformInfo: ((cb) => { 88 | const platformInfo: chrome.runtime.PlatformInfo = { 89 | arch: 'x86-64', 90 | nacl_arch: 'x86-64', 91 | os: 'mac', 92 | } 93 | cb(platformInfo) 94 | }) as typeof chrome.runtime.getPlatformInfo, 95 | onMessage: { 96 | addListener: () => {}, 97 | removeListener: () => {}, 98 | }, 99 | }, 100 | storage: { 101 | local: { 102 | get: ((keys, cb) => { 103 | return cb({ ...mockStorage }) 104 | }) as typeof chrome.storage.local.get, 105 | set: async (items: Record) => { 106 | mockStorage = { ...mockStorage, ...items } 107 | }, 108 | }, 109 | }, 110 | } 111 | 112 | const mockChrome = mockedChrome as typeof chrome 113 | 114 | export { mockChrome } 115 | -------------------------------------------------------------------------------- /src/components/OverflowPopover/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react" 2 | import { Button } from "../Button" 3 | import { ChevronIcon } from "../Icons/ChevronIcon" 4 | import { Popover } from "../Popover" 5 | 6 | interface IOverflowPopoverProps { 7 | className?: string 8 | items: React.ReactNode[] 9 | } 10 | 11 | interface IItemProps { 12 | isVisible?: boolean 13 | children?: React.ReactNode 14 | } 15 | 16 | export const Item = React.forwardRef( 17 | (props, ref) => { 18 | const { children, isVisible = true } = props 19 | 20 | return ( 21 |
    25 | {children} 26 |
    27 | ) 28 | } 29 | ) 30 | 31 | const useVisibilityObserver = ( 32 | refs: React.MutableRefObject<(HTMLDivElement | null)[]> 33 | ) => { 34 | const [itemsOnScreen, setItemOnScreen] = useState([]) 35 | 36 | const handleResize = useCallback(() => { 37 | const windowWidth = window.innerWidth - 30 38 | const elementVisibility = refs.current.map((element) => { 39 | const dims = element?.getBoundingClientRect() 40 | const elementRightEdge = dims?.right || 0 41 | 42 | return elementRightEdge < windowWidth 43 | }) 44 | setItemOnScreen(elementVisibility) 45 | }, [refs]) 46 | 47 | // Noticed when loaded as an extension the delay was 48 | // required otherwise items would not align correctly 49 | useEffect(() => { 50 | handleResize() 51 | const timer = setTimeout(() => { 52 | handleResize() 53 | }, 300) 54 | return () => clearTimeout(timer) 55 | }, [handleResize]) 56 | 57 | useEffect(() => { 58 | window.addEventListener("resize", handleResize, false) 59 | return () => { 60 | window.removeEventListener("resize", handleResize, false) 61 | } 62 | }, [handleResize]) 63 | 64 | return itemsOnScreen 65 | } 66 | 67 | export const OverflowPopover = (props: IOverflowPopoverProps) => { 68 | const { items, className } = props 69 | const itemRefs = useRef<(HTMLDivElement | null)[]>([]) 70 | const itemsOnScreen = useVisibilityObserver(itemRefs) 71 | const isAnyItemOffScreen = itemsOnScreen.some((isOnScreen) => !isOnScreen) 72 | 73 | return ( 74 |
    75 | {items.map((item, index) => { 76 | const isOnScreen = itemsOnScreen[index] ?? true 77 | 78 | return ( 79 | (itemRefs.current[index] = el)} 82 | isVisible={isOnScreen} 83 | > 84 | {item} 85 | 86 | ) 87 | })} 88 | 89 | {isAnyItemOffScreen && ( 90 |
    91 |
    92 | } />}> 93 |
    94 | {items.map((item, index) => { 95 | const isOnScreen = itemsOnScreen[index] 96 | if (isOnScreen) { 97 | return null 98 | } 99 | 100 | return {item} 101 | })} 102 |
    103 |
    104 |
    105 |
    106 | )} 107 |
    108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /src/components/LocalSearchInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | import { Textfield } from '../Textfield' 3 | import { SearchIcon } from '../Icons/SearchIcon' 4 | import { CloseIcon } from '../Icons/CloseIcon' 5 | import { ChevronIcon } from '../Icons/ChevronIcon' 6 | 7 | interface ILocalSearchInputProps { 8 | searchQuery: string 9 | onSearchChange: (query: string) => void 10 | matchCount: number 11 | currentIndex: number 12 | onNext: () => void 13 | onPrevious: () => void 14 | onClose: () => void 15 | } 16 | 17 | export const LocalSearchInput = (props: ILocalSearchInputProps) => { 18 | const { 19 | searchQuery, 20 | onSearchChange, 21 | matchCount, 22 | currentIndex, 23 | onNext, 24 | onPrevious, 25 | onClose, 26 | } = props 27 | const [localValue, setLocalValue] = useState(searchQuery) 28 | 29 | useEffect(() => { 30 | setLocalValue(searchQuery) 31 | }, [searchQuery]) 32 | 33 | const handleChange = (event: React.ChangeEvent) => { 34 | const value = event.target.value 35 | setLocalValue(value) 36 | onSearchChange(value) 37 | } 38 | 39 | const handleKeyDown = useCallback( 40 | (event: React.KeyboardEvent) => { 41 | if (event.key === 'Enter') { 42 | if (event.shiftKey) { 43 | onPrevious() 44 | } else { 45 | onNext() 46 | } 47 | event.preventDefault() 48 | } else if (event.key === 'Escape') { 49 | onClose() 50 | event.preventDefault() 51 | } 52 | }, 53 | [onNext, onPrevious, onClose] 54 | ) 55 | 56 | const hasMatches = matchCount > 0 57 | const displayCount = hasMatches 58 | ? `${currentIndex + 1} of ${matchCount}` 59 | : 'No matches' 60 | 61 | return ( 62 |
    63 | 64 | 75 |
    76 | {searchQuery && displayCount} 77 |
    78 | 87 | 96 | 104 |
    105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-network-inspector", 3 | "version": "2.24.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "craco start", 7 | "build": "REACT_APP_VERSION=$npm_package_version INLINE_RUNTIME_CHUNK=false craco build", 8 | "bundle": "yarn bundle:chrome && yarn bundle:firefox", 9 | "bundle:chrome": "yarn build && node ./scripts/set-chrome-settings.js && bestzip build-chrome.zip build/*", 10 | "bundle:firefox": "yarn build && node ./scripts/set-firefox-settings.js && cd build && bestzip ../build-firefox.zip *", 11 | "postbuild": "node ./scripts/set-manifest-version.js && NODE_ENV=production node ./scripts/set-manifest-content-script.js", 12 | "test": "craco test", 13 | "eject": "react-scripts eject", 14 | "lint": "eslint --max-warnings=0 src/**/*.* public/**/*.js", 15 | "check-types": "tsc --noEmit" 16 | }, 17 | "dependencies": { 18 | "@headlessui/react": "^1.6.0", 19 | "@notdutzi/react-json-view": "^1.21.8", 20 | "@testing-library/react-hooks": "^8.0.1", 21 | "classnames": "^2.3.1", 22 | "copy-to-clipboard": "^3.3.1", 23 | "dotenv": "^16.3.1", 24 | "eventemitter3": "^5.0.1", 25 | "graphql": "^16.0.1", 26 | "graphql-tag": "^2.11.0", 27 | "highlight.js": "^11.5.0", 28 | "mark.js": "^8.11.1", 29 | "mergeby": "^2.0.1", 30 | "pretty-bytes": "^5.6.0", 31 | "pretty-ms": "^7.0.1", 32 | "react": "^17.0.2", 33 | "react-dom": "^17.0.2", 34 | "react-split-pane": "^0.1.92", 35 | "react-table": "^7.5.0", 36 | "react-virtual": "^2.10.4", 37 | "regex-parser": "^2.2.11", 38 | "tailwind-merge": "^1.10.0", 39 | "ts-debounce": "^4.0.0", 40 | "uniqid": "^5.4.0", 41 | "uuid": "^8.3.0" 42 | }, 43 | "devDependencies": { 44 | "@craco/craco": "^6.4.3", 45 | "@tailwindcss/forms": "^0.5.0", 46 | "@testing-library/jest-dom": "^5.11.4", 47 | "@testing-library/react": "^12.1.2", 48 | "@types/chrome": "^0.0.262", 49 | "@types/dedent": "^0.7.0", 50 | "@types/graphql": "^14.5.0", 51 | "@types/jest": "^26.0.24", 52 | "@types/mark.js": "^8.11.7", 53 | "@types/prettier": "^2.6.0", 54 | "@types/react": "^17.0.35", 55 | "@types/react-dom": "^17.0.11", 56 | "@types/react-table": "^7.0.22", 57 | "@types/uniqid": "^5.3.2", 58 | "@types/uuid": "^8.3.0", 59 | "@typescript-eslint/eslint-plugin": "^7.6.0", 60 | "@typescript-eslint/parser": "^7.6.0", 61 | "autoprefixer": "^9", 62 | "bestzip": "^2.2.0", 63 | "copy-webpack-plugin": "^6.4.1", 64 | "dedent": "^0.7.0", 65 | "eslint": "^8.57.0", 66 | "eslint-plugin-react-hooks": "^4.6.0", 67 | "postcss": "^8.4.12", 68 | "prettier": "^2.6.2", 69 | "react-scripts": "^5.0.0", 70 | "tailwindcss": "^3.0.23", 71 | "typescript": "^4.0.3", 72 | "util": "^0.12.5", 73 | "utility-types": "^3.11.0", 74 | "web-streams-polyfill": "^4.0.0" 75 | }, 76 | "_resolutions_comment_": "https://stackoverflow.com/a/71855781/2573621", 77 | "resolutions": { 78 | "@types/react": "17.0.35", 79 | "@types/react-dom": "17.0.11" 80 | }, 81 | "eslintConfig": { 82 | "extends": "react-app" 83 | }, 84 | "browserslist": { 85 | "production": [ 86 | ">0.2%", 87 | "not dead", 88 | "not op_mini all" 89 | ], 90 | "development": [ 91 | "last 1 chrome version", 92 | "last 1 firefox version", 93 | "last 1 safari version" 94 | ] 95 | }, 96 | "jest": { 97 | "moduleNameMapper": { 98 | "^@/(.*)$": "/src/$1" 99 | } 100 | }, 101 | "packageManager": "yarn@1.22.22" 102 | } 103 | -------------------------------------------------------------------------------- /src/containers/SearchPanel/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react" 2 | import { HighlightedText } from "../../components/HighlightedText" 3 | import { ISearchResult } from "../../services/searchService" 4 | import { 5 | getHeaderSearchContent, 6 | getRequestSearchContent, 7 | getResponseSearchContent, 8 | } from "../../helpers/getSearchContent" 9 | import { NetworkTabs } from "../../hooks/useNetworkTabs" 10 | 11 | interface ISearchResultsProps { 12 | searchQuery: string 13 | searchResults: ISearchResult[] 14 | onResultClick: ( 15 | searchResult: ISearchResult, 16 | searchResultType: NetworkTabs 17 | ) => void 18 | } 19 | 20 | interface ISearchResultEntryProps { 21 | searchQuery: string 22 | searchResult: ISearchResult 23 | onResultClick: (searchResultType: NetworkTabs) => void 24 | index: number 25 | } 26 | 27 | interface ISearchResultEntryRowProps { 28 | title: string 29 | onClick: () => void 30 | } 31 | 32 | const SearchResultEntryRow: FC = ({ 33 | title, 34 | children, 35 | onClick, 36 | }) => { 37 | return ( 38 |
    42 |
    {title}
    43 |
    {children}
    44 |
    45 | ) 46 | } 47 | 48 | const SearchResultEntry = (props: ISearchResultEntryProps) => { 49 | const { searchQuery, searchResult, onResultClick, index } = props 50 | const { matches, networkRequest } = searchResult 51 | const { operationName } = networkRequest.request.primaryOperation 52 | 53 | return ( 54 |
    55 |
    {operationName}
    56 | {matches.headers && ( 57 | onResultClick(NetworkTabs.HEADER)} 60 | > 61 | 65 | 66 | )} 67 | {matches.request && ( 68 | onResultClick(NetworkTabs.REQUEST)} 71 | > 72 | 76 | 77 | )} 78 | {matches.response && ( 79 | onResultClick(NetworkTabs.RESPONSE_RAW)} 82 | > 83 | 87 | 88 | )} 89 |
    90 | ) 91 | } 92 | 93 | export const SearchResults = (props: ISearchResultsProps) => { 94 | const { searchQuery, searchResults, onResultClick } = props 95 | return ( 96 |
    97 | {searchResults.map((searchResult, index) => ( 98 | { 104 | onResultClick(searchResult, searchResultType) 105 | }} 106 | /> 107 | ))} 108 |
    109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkDetails/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react" 2 | import { TracingView } from "./TracingView" 3 | import * as safeJson from "@/helpers/safeJson" 4 | 5 | jest.mock("@/hooks/useHighlight", () => ({ 6 | useHighlight: () => ({ 7 | markup: "
    hi
    ", 8 | loading: false, 9 | }), 10 | })) 11 | 12 | describe("NetworkDetails - TracingView", () => { 13 | it("show 'No tracing' message when the tracing object is null", async () => { 14 | const noTracingResponse = safeJson.stringify({ 15 | data: { 16 | signedInUser: { 17 | id: "1", 18 | firstName: "Test", 19 | lastName: "User", 20 | }, 21 | }, 22 | }) 23 | 24 | const { queryByText } = render() 25 | 26 | expect(queryByText("No tracing found.")).toBeVisible() 27 | }) 28 | 29 | it("show total request time", async () => { 30 | const withTracingResponse = safeJson.stringify({ 31 | extensions: { 32 | tracing: { 33 | startTime: "2021-01-01T00:00:00.000Z", 34 | endTime: "2021-01-01T00:00:00.025Z", 35 | duration: 24085701, 36 | execution: { 37 | resolvers: [], 38 | }, 39 | }, 40 | }, 41 | }) 42 | 43 | const { getByText } = render() 44 | 45 | expect(getByText("Total")).toBeVisible() 46 | expect(getByText("24.09 ms")).toBeVisible() 47 | }) 48 | 49 | it("visualize the execution trace", async () => { 50 | const withTracingResponse = safeJson.stringify({ 51 | extensions: { 52 | tracing: { 53 | duration: 1000000, 54 | execution: { 55 | resolvers: [ 56 | { 57 | path: ["signedInUser"], 58 | parentType: "Query", 59 | fieldName: "signedInUser", 60 | returnType: "User", 61 | startOffset: 50000, 62 | duration: 150000, 63 | }, 64 | { 65 | path: ["signedInUser", "id"], 66 | parentType: "Query", 67 | fieldName: "id", 68 | returnType: "ID!", 69 | startOffset: 200000, 70 | duration: 100000, 71 | }, 72 | { 73 | path: ["signedInUser", "firstName"], 74 | parentType: "User", 75 | fieldName: "firstName", 76 | returnType: "String", 77 | startOffset: 200000, 78 | duration: 700000, 79 | }, 80 | { 81 | path: ["signedInUser", "lastName"], 82 | parentType: "User", 83 | fieldName: "lastName", 84 | returnType: "String", 85 | startOffset: 200000, 86 | duration: 750000, 87 | }, 88 | ], 89 | }, 90 | }, 91 | }, 92 | }) 93 | 94 | const { getByText } = render() 95 | 96 | expect(getByText("signedInUser")).toBeVisible() 97 | expect(getByText("0.15 ms")).toBeVisible() 98 | 99 | expect(getByText("signedInUser.id")).toBeVisible() 100 | expect(getByText("0.1 ms")).toBeVisible() 101 | 102 | expect(getByText("signedInUser.firstName")).toBeVisible() 103 | expect(getByText("0.7 ms")).toBeVisible() 104 | 105 | expect(getByText("signedInUser.lastName")).toBeVisible() 106 | expect(getByText("0.75 ms")).toBeVisible() 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /src/components/CodeView/CodeView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useHighlight } from '@/hooks/useHighlight' 3 | import { useByteSize } from '@/hooks/useBytes' 4 | import { useMarkSearch, useLocalMark } from '@/hooks/useMark' 5 | import { useFormattedCode } from '../../hooks/useFormattedCode' 6 | import { DelayedLoader } from '../DelayedLoader' 7 | import { Spinner } from '../Spinner' 8 | import { config } from '../../config' 9 | import classes from './CodeView.module.css' 10 | 11 | interface ICodeViewProps { 12 | text: string 13 | language: 'graphql' | 'json' 14 | autoFormat?: boolean 15 | className?: string 16 | searchQuery?: string 17 | onSearchResults?: (results: { 18 | matchCount: number 19 | currentIndex: number 20 | goToNext: () => void 21 | goToPrevious: () => void 22 | }) => void 23 | } 24 | 25 | const LoadingIndicator = () => { 26 | return ( 27 |
    28 | 29 |
    Formatting...
    30 |
    31 | ) 32 | } 33 | 34 | const CodeTooLargeMessage = () => { 35 | return ( 36 |
    37 | The response payload is too large to display. 38 |
    39 | ) 40 | } 41 | 42 | const CodeRenderer = (props: ICodeViewProps) => { 43 | const { text, language, autoFormat, searchQuery, onSearchResults } = props 44 | const formattedText = useFormattedCode(text, language, autoFormat) 45 | 46 | const { markup: jsonMarkup, loading } = useHighlight(language, formattedText) 47 | 48 | // TODO 49 | // When mark returns results. Show a component to jump to the next/previous 50 | // When the component renders also jump to the first result. 51 | 52 | // Use local search if searchQuery is provided, otherwise use global search 53 | const globalRef = useMarkSearch(jsonMarkup) 54 | const localMarkResult = useLocalMark(searchQuery || '', jsonMarkup) 55 | 56 | const ref = searchQuery ? localMarkResult.ref : globalRef 57 | 58 | // Notify parent of search results if using local search 59 | useEffect(() => { 60 | if (searchQuery && onSearchResults) { 61 | onSearchResults({ 62 | matchCount: localMarkResult.matchCount, 63 | currentIndex: localMarkResult.currentIndex, 64 | goToNext: localMarkResult.goToNext, 65 | goToPrevious: localMarkResult.goToPrevious, 66 | }) 67 | } 68 | }, [ 69 | searchQuery, 70 | onSearchResults, 71 | localMarkResult.matchCount, 72 | localMarkResult.currentIndex, 73 | localMarkResult.goToNext, 74 | localMarkResult.goToPrevious, 75 | ]) 76 | 77 | return ( 78 | }> 79 |
     80 |         
     85 |       
    86 |
    87 | ) 88 | } 89 | 90 | export const CodeView = (props: ICodeViewProps) => { 91 | const { 92 | text, 93 | language, 94 | autoFormat, 95 | className, 96 | searchQuery, 97 | onSearchResults, 98 | } = props 99 | const size = useByteSize(text.length, { unit: 'mb' }) 100 | 101 | return ( 102 |
    103 | {size > config.maxUsableResponseSizeMb ? ( 104 | 105 | ) : ( 106 | 113 | )} 114 |
    115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkTable/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { NetworkTable, INetworkTableDataRow } from "./index" 2 | import { fireEvent, within } from "@testing-library/react" 3 | import { DeepPartial } from "utility-types" 4 | import { render } from "../../../test-utils" 5 | 6 | const request: DeepPartial = { 7 | time: 1000, 8 | status: 200, 9 | url: "https://someurl.com/graphql", 10 | type: "query", 11 | } 12 | 13 | const data = [ 14 | { 15 | id: "1", 16 | ...request, 17 | }, 18 | { 19 | id: "2", 20 | ...request, 21 | }, 22 | { 23 | id: "3", 24 | ...request, 25 | }, 26 | ] as INetworkTableDataRow[] 27 | 28 | describe("NetworkTable", () => { 29 | it("Selects next row when pressing the down arrow", () => { 30 | const mockOnRowSelect = jest.fn() 31 | const { getByTestId } = render( 32 | {}} 35 | onRowSelect={mockOnRowSelect} 36 | selectedRowId="2" 37 | /> 38 | ) 39 | const table = getByTestId("network-table") 40 | 41 | fireEvent.keyDown(table, { code: "ArrowDown" }) 42 | 43 | expect(mockOnRowSelect).toHaveBeenCalledWith("3") 44 | }) 45 | 46 | it("Selects previous row when pressing the up arrow", () => { 47 | const mockOnRowSelect = jest.fn() 48 | const { getByTestId } = render( 49 | {}} 52 | onRowSelect={mockOnRowSelect} 53 | selectedRowId="2" 54 | /> 55 | ) 56 | const table = getByTestId("network-table") 57 | 58 | fireEvent.keyDown(table, { code: "ArrowUp" }) 59 | 60 | expect(mockOnRowSelect).toHaveBeenCalledWith("1") 61 | }) 62 | 63 | it("Remains on bottom row when pressing the down arrow", () => { 64 | const mockOnRowSelect = jest.fn() 65 | const { getByTestId } = render( 66 | {}} 69 | onRowSelect={mockOnRowSelect} 70 | selectedRowId="3" 71 | /> 72 | ) 73 | const table = getByTestId("network-table") 74 | 75 | fireEvent.keyDown(table, { code: "ArrowDown" }) 76 | 77 | expect(mockOnRowSelect).not.toHaveBeenCalled() 78 | }) 79 | 80 | it("Remains on top row when pressing the up arrow", () => { 81 | const mockOnRowSelect = jest.fn() 82 | const { getByTestId } = render( 83 | {}} 86 | onRowSelect={mockOnRowSelect} 87 | selectedRowId="1" 88 | /> 89 | ) 90 | const table = getByTestId("network-table") 91 | 92 | fireEvent.keyDown(table, { code: "ArrowUp" }) 93 | 94 | expect(mockOnRowSelect).not.toHaveBeenCalled() 95 | }) 96 | 97 | it("data is empty - empty table message is rendered", () => { 98 | const { getByTestId } = render( 99 | {}} onRowSelect={() => {}} /> 100 | ) 101 | const table = getByTestId("network-table") 102 | 103 | // ensure the empty table message was rendered 104 | expect( 105 | within(table).getByText("No requests have been detected") 106 | ).toBeInTheDocument() 107 | }) 108 | 109 | it("data is empty and an error message is provided - error message is rendered", () => { 110 | const { getByTestId } = render( 111 | {}} 115 | onRowSelect={() => {}} 116 | /> 117 | ) 118 | const table = getByTestId("network-table") 119 | 120 | // ensure the empty table message was not rendered 121 | expect( 122 | within(table).queryByText("No requests have been detected") 123 | ).not.toBeInTheDocument() 124 | 125 | // ensure the error message was rendered 126 | expect(within(table).getByText("someErrorMessage")).toBeInTheDocument() 127 | }) 128 | }) 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
    2 |

    GraphQL Network Inspector

    3 |

    A better network inspector for viewing and debugging GraphQL requests.

    4 | MIT License 5 | 6 | GitHub Sponsors 7 | 8 |
    9 |
    10 |
    11 | 12 | ![Application Preview](docs/main.jpg) 13 | 14 | A platform agnostic network inspector specifically built for GraphQL. Clearly see individual GraphQL requests including support for query batching. [View the full docs](https://www.overstacked.io/docs/graphql-network-inspector) 15 | 16 | The plugin is available for both Chrome and Firefox: 17 | 18 | 1. [Chrome Webstore](https://chrome.google.com/webstore/detail/graphql-network-inspector/ndlbedplllcgconngcnfmkadhokfaaln) 19 | 20 | 2. [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/graphql-network-inspector) 21 | 22 | ## Features 23 | 24 | We provide a number of productivity features to help you debug and understand your GraphQL requests: 25 | 26 | - Automatically parse and display requests as GraphQL queries. 27 | - Support for query batching, displaying each query individually. 28 | - Export requests to re-run and share with https://www.graphdev.app 29 | 30 | Some shortcuts you may find useful: 31 | 32 | - Click any header to copy to clipboard. 33 | - Double click any JWT token header to both decode and copy to clipboard. 34 | - Press `Cmd/Ctrl + F` to open the full search panel. 35 | 36 | ## Issue with extension not loading 37 | 38 | Some users have raised issues with the extension not loading. This may be due to custom settings in your devtools or conflicts with other extensions. If you are experiencing this issue please try the following: 39 | 40 | 1. Open the devtools and navigate to the settings (cog icon in the top right). 41 | 2. Scroll down to the bottom of the "Preferences" tab and click "Restore defaults and reload". 42 | 43 | If the issue persists please raise an issue with the details of your browser and we'll try to help. 44 | 45 | ## Local Development 46 | 47 | Install dependencies: 48 | 49 | ```bash 50 | yarn 51 | ``` 52 | 53 | Run the development server: 54 | 55 | ```bash 56 | yarn start 57 | ``` 58 | 59 | This will also cache files in the `build` so you can load the directory as an unpacked extension in your browser. Changes will be loaded automatically, however you often have to close and reopen devtools. 60 | 61 | ## Contribute 62 | 63 | PRs are welcome! The best way to do this is to first fork the repository, create a branch and open a pull request back to this repository. 64 | 65 | If you want to add a large feature please first raise an issue to discuss. This avoids wasted effort. 66 | 67 | ## Sponsors 68 | 69 | GraphQL Network Inspector is proudly sponsored by: 70 | 71 | - The Guild https://the-guild.dev 72 | 73 | ## License 74 | 75 | The MIT License (MIT) 76 | 77 | Copyright (c) 2023 GraphQL Network Inspector authors 78 | 79 | Permission is hereby granted, free of charge, to any person obtaining a copy 80 | of this software and associated documentation files (the "Software"), to deal 81 | in the Software without restriction, including without limitation the rights 82 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 83 | copies of the Software, and to permit persons to whom the Software is 84 | furnished to do so, subject to the following conditions: 85 | 86 | The above copyright notice and this permission notice shall be included in all 87 | copies or substantial portions of the Software. 88 | 89 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 90 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 91 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 92 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 93 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 94 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 95 | SOFTWARE. 96 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkDetails/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { Tabs } from '@/components/Tabs' 3 | import { ICompleteNetworkRequest } from '@/helpers/networkHelpers' 4 | import { HeaderView } from '../HeaderView' 5 | import { RequestView, RequestViewFooter } from './RequestView' 6 | import { ResponseView } from './ResponseView' 7 | import { TracingView } from './TracingView' 8 | import { ResponseRawView } from './ResponseRawView' 9 | import { useNetworkTabs } from '@/hooks/useNetworkTabs' 10 | import { CloseButton } from '@/components/CloseButton' 11 | import { useApolloTracing } from '@/hooks/useApolloTracing' 12 | import { useToggle } from '@/hooks/useToggle' 13 | import { useShareMessage } from '../../../hooks/useShareMessage' 14 | 15 | export interface INetworkDetailsProps { 16 | data: ICompleteNetworkRequest 17 | onClose: () => void 18 | } 19 | 20 | export const NetworkDetails = (props: INetworkDetailsProps) => { 21 | const { data, onClose } = props 22 | const { activeTab, setActiveTab } = useNetworkTabs() 23 | const requestHeaders = data.request.headers 24 | const responseHeaders = data.response?.headers || [] 25 | const requestBody = data.request.body 26 | const responseBody = data.response?.body 27 | const responseChunks = data.response?.chunks 28 | const isStreaming = data.response?.isStreaming 29 | const responseCollapsedCount = requestBody.length > 1 ? 3 : 2 30 | const tracing = useApolloTracing(responseBody) 31 | const [autoFormat, toggleAutoFormat] = useToggle() 32 | const { shareNetworkRequest } = useShareMessage() 33 | const operation = data.request.primaryOperation.operation 34 | const isShareable = useMemo( 35 | () => ['query', 'mutation'].includes(operation), 36 | [operation] 37 | ) 38 | 39 | const handleShare = () => { 40 | shareNetworkRequest(data) 41 | } 42 | 43 | return ( 44 | 51 | 52 |
    53 | } 54 | tabs={[ 55 | { 56 | id: 'headers', 57 | title: 'Headers', 58 | component: ( 59 | 63 | ), 64 | }, 65 | { 66 | id: 'request', 67 | title: 'Request', 68 | component: ( 69 | 75 | ), 76 | bottomComponent: ( 77 | 81 | ), 82 | }, 83 | { 84 | id: 'response', 85 | title: 'Response', 86 | component: ( 87 | 94 | ), 95 | }, 96 | { 97 | id: 'response-raw', 98 | title: 'Response (Raw)', 99 | component: ( 100 | 104 | ), 105 | }, 106 | ...(tracing 107 | ? [ 108 | { 109 | id: 'tracing', 110 | title: 'Tracing', 111 | component: , 112 | }, 113 | ] 114 | : []), 115 | ]} 116 | /> 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /src/hooks/useMark.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | import Mark from 'mark.js' 3 | import { useSearch } from './useSearch' 4 | 5 | /** 6 | * Mark given text. 7 | * @param content Optional content to listen for changes to causing mark to re-run. 8 | * 9 | * @returns ref to the element to mark 10 | */ 11 | export const useMark = ( 12 | searchQuery: string, 13 | content?: string, 14 | done?: () => void 15 | ) => { 16 | const ref = useRef(null) 17 | 18 | useEffect(() => { 19 | if (!ref.current) { 20 | return 21 | } 22 | 23 | const mark = new Mark(ref.current) 24 | mark.mark(searchQuery, { 25 | caseSensitive: false, 26 | separateWordSearch: false, 27 | done: () => done?.(), 28 | }) 29 | 30 | return () => { 31 | mark.unmark() 32 | } 33 | }, [ref, searchQuery, content, done]) 34 | 35 | return ref 36 | } 37 | 38 | /** 39 | * Mark text that has been searched from the search provider. 40 | * 41 | * @param content Optional string to cause mark to re-run when changed. 42 | * 43 | * @returns ref to the element to mark 44 | */ 45 | export const useMarkSearch = (content?: string) => { 46 | const { searchQuery } = useSearch() 47 | 48 | // Once mark is complete we can jump to the first result 49 | const onMarkDone = useCallback(() => { 50 | const element = document.querySelector('mark[data-markjs="true"]') 51 | element?.scrollIntoView() 52 | }, []) 53 | 54 | const ref = useMark(searchQuery, content, onMarkDone) 55 | 56 | return ref 57 | } 58 | 59 | /** 60 | * Mark text with local search functionality including match tracking and navigation. 61 | * 62 | * @param searchQuery The search query to mark 63 | * @param content Optional content to listen for changes to causing mark to re-run. 64 | * 65 | * @returns ref to the element to mark, match count, current index, and navigation functions 66 | */ 67 | export const useLocalMark = (searchQuery: string, content?: string) => { 68 | const ref = useRef(null) 69 | const [matchCount, setMatchCount] = useState(0) 70 | const [currentIndex, setCurrentIndex] = useState(0) 71 | 72 | const scrollToCurrentMark = useCallback((index: number) => { 73 | const marks = document.querySelectorAll('mark[data-markjs="true"]') 74 | if (marks.length > 0 && index >= 0 && index < marks.length) { 75 | // Remove highlight from all marks 76 | marks.forEach((mark) => { 77 | mark.classList.remove('bg-yellow-400', 'dark:bg-yellow-600') 78 | mark.classList.add('bg-yellow-200', 'dark:bg-yellow-800') 79 | }) 80 | 81 | // Highlight current mark 82 | const currentMark = marks[index] 83 | currentMark.classList.remove('bg-yellow-200', 'dark:bg-yellow-800') 84 | currentMark.classList.add('bg-yellow-400', 'dark:bg-yellow-600') 85 | currentMark.scrollIntoView({ behavior: 'smooth', block: 'center' }) 86 | } 87 | }, []) 88 | 89 | useEffect(() => { 90 | if (!ref.current || !searchQuery) { 91 | setMatchCount(0) 92 | setCurrentIndex(0) 93 | return 94 | } 95 | 96 | const mark = new Mark(ref.current) 97 | mark.mark(searchQuery, { 98 | caseSensitive: false, 99 | separateWordSearch: false, 100 | className: 'bg-yellow-200 dark:bg-yellow-800', 101 | done: () => { 102 | const marks = document.querySelectorAll('mark[data-markjs="true"]') 103 | setMatchCount(marks.length) 104 | if (marks.length > 0) { 105 | setCurrentIndex(0) 106 | scrollToCurrentMark(0) 107 | } 108 | }, 109 | }) 110 | 111 | return () => { 112 | mark.unmark() 113 | } 114 | }, [ref, searchQuery, content, scrollToCurrentMark]) 115 | 116 | const goToNext = useCallback(() => { 117 | if (matchCount === 0) return 118 | const nextIndex = (currentIndex + 1) % matchCount 119 | setCurrentIndex(nextIndex) 120 | scrollToCurrentMark(nextIndex) 121 | }, [currentIndex, matchCount, scrollToCurrentMark]) 122 | 123 | const goToPrevious = useCallback(() => { 124 | if (matchCount === 0) return 125 | const prevIndex = currentIndex === 0 ? matchCount - 1 : currentIndex - 1 126 | setCurrentIndex(prevIndex) 127 | scrollToCurrentMark(prevIndex) 128 | }, [currentIndex, matchCount, scrollToCurrentMark]) 129 | 130 | return { 131 | ref, 132 | matchCount, 133 | currentIndex, 134 | goToNext, 135 | goToPrevious, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkDetails/ResponseRawView.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useEffect, useCallback } from 'react' 2 | import * as safeJson from '@/helpers/safeJson' 3 | import { CopyButton } from '@/components/CopyButton' 4 | import { CodeView } from '@/components/CodeView' 5 | import { ShareButton } from '../../../components/ShareButton' 6 | import { LocalSearchInput } from '@/components/LocalSearchInput' 7 | 8 | interface IResponseRawViewProps { 9 | response?: string 10 | onShare?: () => void 11 | } 12 | 13 | const useFormatResponse = (response?: string): string => { 14 | return useMemo(() => { 15 | if (!response) { 16 | return '{}' 17 | } 18 | 19 | // We remove the "extensions" prop as this is just meta data 20 | // for things like "tracing" and can be huge in size. 21 | const parsedResponse = safeJson.parse<{ extensions?: string }>(response) 22 | if (!parsedResponse) { 23 | return '' 24 | } 25 | 26 | if ('extensions' in parsedResponse) { 27 | delete parsedResponse['extensions'] 28 | } 29 | 30 | return safeJson.stringify(parsedResponse, undefined, 2) 31 | }, [response]) 32 | } 33 | 34 | export const ResponseRawView = (props: IResponseRawViewProps) => { 35 | const { response, onShare } = props 36 | const formattedJson = useFormatResponse(response) 37 | const [isSearchOpen, setIsSearchOpen] = useState(false) 38 | const [searchQuery, setSearchQuery] = useState('') 39 | const [searchResults, setSearchResults] = useState<{ 40 | matchCount: number 41 | currentIndex: number 42 | goToNext: () => void 43 | goToPrevious: () => void 44 | }>({ 45 | matchCount: 0, 46 | currentIndex: 0, 47 | goToNext: () => {}, 48 | goToPrevious: () => {}, 49 | }) 50 | 51 | // Handle Cmd/Ctrl+F to open search - using same pattern as useSearchStart 52 | useEffect(() => { 53 | const getIsCommandKeyPressed = (event: KeyboardEvent) => { 54 | return event.code === 'MetaLeft' || event.code === 'ControlLeft' 55 | } 56 | 57 | let isCommandKeyPressed = false 58 | 59 | const handleKeyDown = (event: KeyboardEvent) => { 60 | if (getIsCommandKeyPressed(event)) { 61 | isCommandKeyPressed = true 62 | } else if (event.code === 'KeyF' && isCommandKeyPressed) { 63 | event.preventDefault() 64 | event.stopPropagation() 65 | setIsSearchOpen(true) 66 | } 67 | } 68 | 69 | const handleKeyUp = (event: KeyboardEvent) => { 70 | if (getIsCommandKeyPressed(event)) { 71 | isCommandKeyPressed = false 72 | } 73 | } 74 | 75 | document.addEventListener('keydown', handleKeyDown, true) // Use capture phase 76 | document.addEventListener('keyup', handleKeyUp, true) 77 | 78 | return () => { 79 | document.removeEventListener('keydown', handleKeyDown, true) 80 | document.removeEventListener('keyup', handleKeyUp, true) 81 | } 82 | }, []) 83 | 84 | const handleSearchClose = useCallback(() => { 85 | setIsSearchOpen(false) 86 | setSearchQuery('') 87 | }, []) 88 | 89 | const handleSearchResults = useCallback( 90 | (results: { 91 | matchCount: number 92 | currentIndex: number 93 | goToNext: () => void 94 | goToPrevious: () => void 95 | }) => { 96 | setSearchResults(results) 97 | }, 98 | [] 99 | ) 100 | 101 | return ( 102 | <> 103 |
    104 |
    105 | {onShare && } 106 | 107 |
    108 | 115 |
    116 | {isSearchOpen && ( 117 |
    118 | 127 |
    128 | )} 129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /src/components/Table/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useTable, 3 | HeaderGroup, 4 | TableInstance, 5 | TableOptions, 6 | Row, 7 | } from "react-table" 8 | import { useMaintainScrollBottom } from "../../hooks/useMaintainScrollBottom" 9 | 10 | type RowId = string | number 11 | 12 | export type ITableProps = TableOptions & { 13 | error?: string 14 | onRowClick?: (rowId: RowId, data: Row["original"]) => void 15 | selectedRowId?: RowId | null 16 | isScollBottomMaintained?: boolean 17 | } 18 | 19 | type ITableBodyProps = TableInstance & { 20 | onRowClick?: (data: Row) => void 21 | selectedRowId?: RowId | null 22 | } 23 | 24 | type IBaseRowData = { 25 | id: RowId 26 | } 27 | 28 | const TableHead = ({ 29 | headerGroups, 30 | }: { 31 | headerGroups: HeaderGroup[] 32 | }) => ( 33 |
    34 | {headerGroups.map(({ getHeaderGroupProps, headers }) => ( 35 | 36 | {headers.map(({ getHeaderProps, render }) => ( 37 | 43 | ))} 44 | 45 | ))} 46 | 47 | ) 48 | 49 | const TableBody = ({ 50 | rows, 51 | getTableBodyProps, 52 | prepareRow, 53 | onRowClick, 54 | selectedRowId, 55 | }: ITableBodyProps) => ( 56 | 57 | {rows.map((row) => { 58 | prepareRow(row) 59 | 60 | const isSelected = row.original.id === selectedRowId 61 | const props = row.getRowProps() 62 | const style = { ...props.style, scrollMargin: "31px" } 63 | 64 | return ( 65 | 75 | {row.cells.map((cell) => ( 76 | 83 | ))} 84 | 85 | ) 86 | })} 87 | 88 | ) 89 | 90 | interface INoResultsProps { 91 | message?: string 92 | data: readonly any[] 93 | } 94 | 95 | const NoResults = (props: INoResultsProps) => { 96 | const { message = "No requests have been detected", data } = props 97 | 98 | if (data.length === 0) { 99 | return ( 100 |
    101 |
    {message}
    102 |
    103 | ) 104 | } 105 | 106 | return null 107 | } 108 | 109 | export const Table = (props: ITableProps) => { 110 | const { 111 | columns, 112 | data, 113 | error, 114 | onRowClick, 115 | selectedRowId, 116 | isScollBottomMaintained, 117 | } = props 118 | const tableInstance = useTable({ columns, data }) 119 | const { getTableProps, headerGroups } = tableInstance 120 | const ref = useMaintainScrollBottom({ 121 | totalEntries: data.length, 122 | isActive: isScollBottomMaintained, 123 | }) 124 | 125 | return ( 126 |
    131 |
    41 | {render("Header")} 42 |
    onRowClick(row) : undefined} 79 | className="p-2 border-r border-gray-300 dark:border-gray-600 last:border-r-0" 80 | > 81 | {cell.render("Cell")} 82 |
    136 | 137 | { 140 | if (onRowClick) { 141 | onRowClick(row.original.id, row.original) 142 | } 143 | }} 144 | selectedRowId={selectedRowId} 145 | /> 146 |
    147 | 148 | 149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /src/containers/NetworkPanel/NetworkDetails/ResponseView.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react" 2 | import * as safeJson from "../../../helpers/safeJson" 3 | import { JsonView } from "@/components/CodeView" 4 | import { CopyButton } from "../../../components/CopyButton" 5 | import { ShareButton } from "../../../components/ShareButton" 6 | import { IResponseChunk } from "@/helpers/networkHelpers" 7 | import { mergeResponseChunks, formatIncrementalResponseTimeline } from "@/helpers/incrementalResponseHelpers" 8 | 9 | interface IResponseViewProps { 10 | response?: string 11 | collapsed?: number 12 | onShare?: () => void 13 | chunks?: IResponseChunk[] 14 | isStreaming?: boolean 15 | } 16 | 17 | export const ResponseView = (props: IResponseViewProps) => { 18 | const { response, collapsed, onShare, chunks, isStreaming } = props 19 | const [viewMode, setViewMode] = useState<'merged' | 'timeline'>('merged') 20 | 21 | const { formattedJson, parsedResponse, hasIncrementalData } = useMemo(() => { 22 | // If we have chunks, merge them 23 | if (chunks && chunks.length > 0) { 24 | const { mergedData, hasIncrementalData } = mergeResponseChunks(chunks) 25 | 26 | // Remove hasNext from the display (it's internal to incremental delivery) 27 | const displayData = mergedData ? { ...mergedData } : {} 28 | if (displayData.hasNext !== undefined) { 29 | delete displayData.hasNext 30 | } 31 | 32 | return { 33 | formattedJson: safeJson.stringify(displayData, undefined, 2), 34 | parsedResponse: displayData, 35 | hasIncrementalData, 36 | } 37 | } 38 | 39 | // Otherwise, use the single response 40 | const parsedResponse = safeJson.parse(response) || {} 41 | return { 42 | formattedJson: safeJson.stringify(parsedResponse, undefined, 2), 43 | parsedResponse, 44 | hasIncrementalData: false, 45 | } 46 | }, [response, chunks]) 47 | 48 | const timeline = useMemo(() => { 49 | if (chunks && chunks.length > 0) { 50 | return formatIncrementalResponseTimeline(chunks) 51 | } 52 | return [] 53 | }, [chunks]) 54 | 55 | return ( 56 |
    57 |
    58 | {hasIncrementalData && ( 59 | <> 60 | 70 | 80 | 81 | )} 82 | {onShare && } 83 | 84 |
    85 | 86 | {isStreaming && ( 87 |
    88 | Incremental Response (@defer/@stream) - {chunks?.length || 0} chunk(s) 89 |
    90 | )} 91 | 92 | {viewMode === 'merged' ? ( 93 | 94 | ) : ( 95 |
    96 | {timeline.map((item, index) => ( 97 |
    98 |
    99 | 100 | Chunk {item.chunkIndex + 1} 101 | {item.isIncremental && ( 102 | 103 | Incremental 104 | 105 | )} 106 | 107 |
    108 | 109 |
    110 | ))} 111 |
    112 | )} 113 |
    114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src/containers/App/Search.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, within, waitFor } from '@testing-library/react' 2 | import { App } from './index' 3 | 4 | jest.mock('@/hooks/useHighlight', () => ({ 5 | useHighlight: () => ({ 6 | markup: '
    hi
    ', 7 | loading: false, 8 | }), 9 | })) 10 | 11 | describe('App - Search', () => { 12 | it('search panel is closed by default', async () => { 13 | const { queryByTestId, getByText } = render() 14 | 15 | await waitFor(() => { 16 | expect(getByText(/getMovie/i)).toBeInTheDocument() 17 | }) 18 | 19 | expect(queryByTestId('search-panel')).not.toBeInTheDocument() 20 | }) 21 | 22 | it('opens the search panel when clicking the search button', async () => { 23 | const { getByTestId, getByText } = render() 24 | 25 | await waitFor(() => { 26 | expect(getByText(/getMovie/i)).toBeInTheDocument() 27 | }) 28 | 29 | fireEvent.click(getByTestId('search-button')) 30 | expect(getByTestId('search-panel')).toBeInTheDocument() 31 | }) 32 | 33 | it('opens the getMovieQuery when clicking the search result', async () => { 34 | const { getByTestId, getByText } = render() 35 | 36 | await waitFor(() => { 37 | expect(getByText(/getMovie/i)).toBeInTheDocument() 38 | }) 39 | 40 | fireEvent.click(getByTestId('search-button')) 41 | const searchInput = getByTestId('search-input') 42 | fireEvent.change(searchInput, { 43 | target: { value: 'getmovie' }, 44 | }) 45 | fireEvent.keyDown(searchInput, { key: 'Enter', code: 'Enter' }) 46 | 47 | fireEvent.click( 48 | within(getByTestId('search-results-0')).getByText('Request') 49 | ) 50 | 51 | const rows = within(getByTestId('network-table')).getAllByRole('row') 52 | expect(rows[1]).toHaveAttribute('aria-selected', 'true') 53 | expect(rows[1]).toHaveTextContent('getMovieQuery') 54 | }) 55 | 56 | it('searches after debounce delay', async () => { 57 | const { getByTestId, findByTestId, getByText } = render() 58 | 59 | await waitFor(() => { 60 | expect(getByText(/getMovie/i)).toBeInTheDocument() 61 | }) 62 | 63 | fireEvent.click(getByTestId('search-button')) 64 | const searchInput = getByTestId('search-input') 65 | fireEvent.change(searchInput, { 66 | target: { value: 'getmovie' }, 67 | }) 68 | 69 | expect(await findByTestId('search-results-0')).toBeInTheDocument() 70 | }) 71 | 72 | it('open the headers tab when clicking a search result', async () => { 73 | const { getByTestId, getByText } = render() 74 | 75 | await waitFor(() => { 76 | expect(getByText(/getMovie/i)).toBeInTheDocument() 77 | }) 78 | 79 | fireEvent.click(getByTestId('search-button')) 80 | 81 | const searchInput = getByTestId('search-input') 82 | fireEvent.change(searchInput, { 83 | target: { value: 'auth' }, 84 | }) 85 | fireEvent.keyDown(searchInput, { key: 'Enter', code: 'Enter' }) 86 | 87 | fireEvent.click(within(getByTestId('search-results-0')).getByText('Header')) 88 | 89 | expect(getByTestId('tab-button-headers')).toHaveAttribute( 90 | 'aria-selected', 91 | 'true' 92 | ) 93 | }) 94 | 95 | it('open the request tab when clicking a search result', async () => { 96 | const { getByTestId, getByText } = render() 97 | 98 | await waitFor(() => { 99 | expect(getByText(/getMovie/i)).toBeInTheDocument() 100 | }) 101 | 102 | fireEvent.click(getByTestId('search-button')) 103 | 104 | const searchInput = getByTestId('search-input') 105 | fireEvent.change(searchInput, { 106 | target: { value: 'getmovie' }, 107 | }) 108 | fireEvent.keyDown(searchInput, { key: 'Enter', code: 'Enter' }) 109 | 110 | fireEvent.click( 111 | within(getByTestId('search-results-0')).getByText('Request') 112 | ) 113 | 114 | expect(getByTestId('tab-button-request')).toHaveAttribute( 115 | 'aria-selected', 116 | 'true' 117 | ) 118 | }) 119 | 120 | it('open the response (raw) tab when clicking a search result', async () => { 121 | const { getByTestId, getByText } = render() 122 | 123 | await waitFor(() => { 124 | expect(getByText(/getMovie/i)).toBeInTheDocument() 125 | }) 126 | 127 | fireEvent.click(getByTestId('search-button')) 128 | 129 | const searchInput = getByTestId('search-input') 130 | fireEvent.change(searchInput, { 131 | target: { value: 'batman' }, 132 | }) 133 | fireEvent.keyDown(searchInput, { key: 'Enter', code: 'Enter' }) 134 | 135 | fireEvent.click( 136 | within(getByTestId('search-results-0')).getByText('Response') 137 | ) 138 | 139 | expect(getByTestId('tab-button-response-raw')).toHaveAttribute( 140 | 'aria-selected', 141 | 'true' 142 | ) 143 | }) 144 | }) 145 | --------------------------------------------------------------------------------