From 5664d1cde4b512d491613d7c22af8d45ddd02341 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Sun, 1 Mar 2026 15:55:49 -0500 Subject: [PATCH 01/30] Initial devtool functionality of listing GQL requests --- CLAUDE.md | 2 +- entrypoints/devtools-panel/App.css | 74 ++++ entrypoints/devtools-panel/App.tsx | 45 +++ .../devtools-panel/__tests__/App.test.tsx | 83 +++++ .../devtools-panel/__tests__/har.test.ts | 350 ++++++++++++++++++ .../__tests__/useGraphQLRequests.test.ts | 139 +++++++ entrypoints/devtools-panel/har.ts | 99 +++++ entrypoints/devtools-panel/index.html | 12 + entrypoints/devtools-panel/main.tsx | 10 + .../devtools-panel/useGraphQLRequests.ts | 32 ++ entrypoints/devtools/__tests__/main.test.ts | 17 + entrypoints/devtools/index.html | 10 + entrypoints/devtools/main.ts | 3 + package.json | 4 + pnpm-lock.yaml | 114 ++++-- 15 files changed, 953 insertions(+), 41 deletions(-) create mode 100644 entrypoints/devtools-panel/App.css create mode 100644 entrypoints/devtools-panel/App.tsx create mode 100644 entrypoints/devtools-panel/__tests__/App.test.tsx create mode 100644 entrypoints/devtools-panel/__tests__/har.test.ts create mode 100644 entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts create mode 100644 entrypoints/devtools-panel/har.ts create mode 100644 entrypoints/devtools-panel/index.html create mode 100644 entrypoints/devtools-panel/main.tsx create mode 100644 entrypoints/devtools-panel/useGraphQLRequests.ts create mode 100644 entrypoints/devtools/__tests__/main.test.ts create mode 100644 entrypoints/devtools/index.html create mode 100644 entrypoints/devtools/main.ts diff --git a/CLAUDE.md b/CLAUDE.md index bf7d104..8e59b69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ GraphiTab is a browser extension (Chrome/Firefox) that provides GraphiQL (a Grap - `pnpm build` — production build (Chrome) - `pnpm build:firefox` — production build (Firefox) - `pnpm test` — type-check then run unit tests -- `pnpm test -- utils/__tests__/profiles.test.ts` — run a single test file +- `pnpm vitest run utils/__tests__/profiles.test.ts` — run a single test file (skips type-check) - `pnpm test:e2e` — build extension then run Playwright E2E tests - `pnpm compile` — TypeScript type checking (`tsc --noEmit`) - `pnpm lint` — lint with oxlint diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css new file mode 100644 index 0000000..e10aac7 --- /dev/null +++ b/entrypoints/devtools-panel/App.css @@ -0,0 +1,74 @@ +html, +body { + height: 100%; + margin: 0; +} + +#root { + height: 100%; +} + +.graphiql-container { + height: 100%; +} + +.gt-devtools-panel { + display: flex; + flex-direction: column; + height: 100%; + font-family: var(--font-family); + font-size: var(--font-size-body); + overflow: auto; +} + +.gt-network-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.gt-network-table th, +.gt-network-table td { + padding: var(--px-6) var(--px-8); + text-align: left; + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gt-network-table th { + font-weight: 600; + color: hsl(var(--color-neutral)); + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + position: sticky; + top: 0; + z-index: 1; +} + +.gt-network-table th:nth-child(1) { + width: 20%; +} +.gt-network-table th:nth-child(2) { + width: 10%; +} +.gt-network-table th:nth-child(3) { + width: 10%; +} +.gt-network-table th:nth-child(4) { + width: 10%; +} +.gt-network-table th:nth-child(5) { + width: 50%; +} + +.gt-network-table tbody tr:hover { + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + cursor: pointer; +} + +.gt-network-empty td { + text-align: center; + color: hsla(var(--color-neutral), var(--alpha-tertiary)); + padding: var(--px-16); +} diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx new file mode 100644 index 0000000..a49f343 --- /dev/null +++ b/entrypoints/devtools-panel/App.tsx @@ -0,0 +1,45 @@ +import { filesize } from 'filesize' +import prettyMs from 'pretty-ms' + +import 'graphiql/style.css' +import './App.css' +import { useGraphQLRequests } from './useGraphQLRequests' + +export default function App() { + const requests = useGraphQLRequests() + + return ( +
+
+ + + + + + + + + + + + {requests.length === 0 ? ( + + + + ) : ( + requests.map((req) => ( + + + + + + + + )) + )} + +
OperationStatusSizeTimeURL
No GraphQL requests recorded.
{req.operationName}{req.status}{filesize(req.size)}{prettyMs(req.time)}{req.url}
+
+
+ ) +} diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx new file mode 100644 index 0000000..fb396bf --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -0,0 +1,83 @@ +import { cleanup, render, screen } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest' +import '@testing-library/jest-dom/vitest' + +vi.mock('../App.css', () => ({})) +vi.mock('graphiql/style.css', () => ({})) +vi.mock('../useGraphQLRequests', () => ({ useGraphQLRequests: vi.fn() })) + +import App from '../App' +import type { GraphQLRequest } from '../har' +import { useGraphQLRequests } from '../useGraphQLRequests' + +const mockUseGraphQLRequests = vi.mocked(useGraphQLRequests) + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + ...overrides, + } +} + +describe('DevTools Panel App', () => { + afterEach(() => { + cleanup() + }) + + it('renders all 5 column headers', () => { + mockUseGraphQLRequests.mockReturnValue([]) + render() + expect(screen.getByText('Operation')).toBeInTheDocument() + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('Size')).toBeInTheDocument() + expect(screen.getByText('Time')).toBeInTheDocument() + expect(screen.getByText('URL')).toBeInTheDocument() + }) + + it('shows empty state when hook returns []', () => { + mockUseGraphQLRequests.mockReturnValue([]) + render() + expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument() + }) + + it('renders a row per request with operation name, status, and URL', () => { + mockUseGraphQLRequests.mockReturnValue([ + makeRequest({ + id: '1', + operationName: 'GetHero', + status: 200, + url: 'https://api.example.com/graphql', + }), + makeRequest({ + id: '2', + operationName: 'CreateUser', + status: 201, + url: 'https://api.example.com/graphql', + }), + ]) + render() + expect(screen.getByText('GetHero')).toBeInTheDocument() + expect(screen.getByText('CreateUser')).toBeInTheDocument() + expect(screen.getAllByText('200')).toHaveLength(1) + expect(screen.getByText('201')).toBeInTheDocument() + expect(screen.getAllByText('https://api.example.com/graphql')).toHaveLength(2) + }) + + it('filesize formats size correctly: 512 → "512 B"', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ size: 512 })]) + render() + expect(screen.getByText('512 B')).toBeInTheDocument() + }) + + it('prettyMs formats time correctly: 123 → "123ms"', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ time: 123 })]) + render() + expect(screen.getByText('123ms')).toBeInTheDocument() + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts new file mode 100644 index 0000000..00edccc --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect } from 'vitest' + +import { isGraphQLEntry, extractOperationName } from '../har' +import type { HAREntry } from '../har' + +function makeEntry(overrides: Partial = {}): HAREntry { + return { + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '{ hero { name } }' }) }, + ...overrides.request, + }, + response: { + status: 200, + content: { size: 512 }, + ...overrides.response, + }, + time: 123, + ...overrides, + } +} + +describe('isGraphQLEntry', () => { + it('POST with application/json and query field → true', () => { + expect(isGraphQLEntry(makeEntry())).toBe(true) + }) + + it('POST with application/json but no query field → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ mutation: 'stuff' }) }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('POST with missing content-type header → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [], + postData: { text: JSON.stringify({ query: '{ hero }' }) }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('POST with wrong content-type → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'text/plain' }], + postData: { text: JSON.stringify({ query: '{ hero }' }) }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('POST with invalid JSON body → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: 'not-json' }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('POST with no body → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('GET with query param → true', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'https://example.com/graphql?query=%7B%20hero%20%7D', + headers: [], + }, + }) + expect(isGraphQLEntry(entry)).toBe(true) + }) + + it('GET without query param → false', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'https://example.com/api?foo=bar', + headers: [], + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('GET with malformed URL → false', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'not a url', + headers: [], + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('PUT method → false', () => { + const entry = makeEntry({ + request: { + method: 'PUT', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '{ hero }' }) }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('DELETE method → false', () => { + const entry = makeEntry({ + request: { + method: 'DELETE', + url: 'https://example.com/graphql', + headers: [], + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) +}) + +describe('extractOperationName', () => { + it('POST: explicit operationName field → returned trimmed', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify({ operationName: ' GetHero ', query: 'query GetHero { hero }' }), + }, + }, + }) + expect(extractOperationName(entry)).toBe('GetHero') + }) + + it('POST: blank operationName falls through to query parsing', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify({ operationName: ' ', query: 'query MyQuery { hero }' }), + }, + }, + }) + expect(extractOperationName(entry)).toBe('MyQuery') + }) + + it('POST: named query operation → name from AST', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('GetHero') + }) + + it('POST: named mutation operation → name from AST', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('CreateUser') + }) + + it('POST: named subscription operation → name from AST', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'subscription OnMessage { message { id } }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('OnMessage') + }) + + it('POST: anonymous operation with keyword → capitalized type', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'mutation { createUser { id } }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('Mutation') + }) + + it('POST: shorthand query → "Query"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '{ hero }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('Query') + }) + + it('POST: invalid query string → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '!@#invalid' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('POST: no body → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('POST: invalid JSON → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: 'not-json' }, + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('GET: operationName param present → returned trimmed', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'https://example.com/graphql?query=%7B%20hero%20%7D&operationName=GetHero', + headers: [], + }, + }) + expect(extractOperationName(entry)).toBe('GetHero') + }) + + it('GET: no operationName, query param with named op → name from AST', () => { + const query = encodeURIComponent('query GetHero { hero { name } }') + const entry = makeEntry({ + request: { + method: 'GET', + url: `https://example.com/graphql?query=${query}`, + headers: [], + }, + }) + expect(extractOperationName(entry)).toBe('GetHero') + }) + + it('GET: no operationName, shorthand query → "Query"', () => { + const query = encodeURIComponent('{ hero }') + const entry = makeEntry({ + request: { + method: 'GET', + url: `https://example.com/graphql?query=${query}`, + headers: [], + }, + }) + expect(extractOperationName(entry)).toBe('Query') + }) + + it('GET: malformed URL → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'not a url', + headers: [], + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('POST: body has no query field (non-string query) → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ variables: { id: '1' } }) }, + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('GET: valid URL with no query param → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'https://example.com/graphql?variables=%7B%7D', + headers: [], + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) + + it('fragment-only document (no OperationDefinition) → "Anonymous"', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'fragment F on Query { hero { name } }' }) }, + }, + }) + expect(extractOperationName(entry)).toBe('Anonymous') + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts new file mode 100644 index 0000000..1d8dbd5 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -0,0 +1,139 @@ +import { renderHook, act } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import type { HAREntry } from '../har' +import { useGraphQLRequests } from '../useGraphQLRequests' + +type Listener = (entry: HAREntry) => void + +function makeChromeMock() { + let capturedListener: Listener | null = null + const addListener = vi.fn((fn: Listener) => { + capturedListener = fn + }) + const removeListener = vi.fn((fn: Listener) => { + if (capturedListener === fn) capturedListener = null + }) + + const chrome = { + devtools: { + network: { + onRequestFinished: { addListener, removeListener }, + }, + }, + } + + function fire(entry: HAREntry) { + capturedListener?.(entry) + } + + return { chrome, addListener, removeListener, fire } +} + +function makeGraphQLEntry(overrides: Partial = {}): HAREntry { + return { + request: { + method: 'POST', + url: 'https://api.example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, + ...overrides, + }, + response: { status: 200, content: { size: 512 } }, + time: 123, + } +} + +function makeNonGraphQLEntry(): HAREntry { + return { + request: { + method: 'GET', + url: 'https://api.example.com/rest', + headers: [], + }, + response: { status: 200, content: { size: 100 } }, + time: 50, + } +} + +describe('useGraphQLRequests', () => { + let mock: ReturnType + + beforeEach(() => { + mock = makeChromeMock() + vi.stubGlobal('chrome', mock.chrome) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns empty array initially', () => { + const { result } = renderHook(() => useGraphQLRequests()) + expect(result.current).toEqual([]) + }) + + it('registers listener on mount', () => { + renderHook(() => useGraphQLRequests()) + expect(mock.addListener).toHaveBeenCalledOnce() + }) + + it('removes same listener on unmount', () => { + const { unmount } = renderHook(() => useGraphQLRequests()) + const registeredFn = mock.addListener.mock.calls[0][0] + unmount() + expect(mock.removeListener).toHaveBeenCalledWith(registeredFn) + }) + + it('adds a GraphQLRequest entry when a matching HAR entry fires', () => { + const { result } = renderHook(() => useGraphQLRequests()) + act(() => { + mock.fire(makeGraphQLEntry()) + }) + expect(result.current).toHaveLength(1) + expect(result.current[0]).toMatchObject({ + id: '1', + operationName: 'GetHero', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + }) + }) + + it('ignores non-GraphQL entries', () => { + const { result } = renderHook(() => useGraphQLRequests()) + act(() => { + mock.fire(makeNonGraphQLEntry()) + }) + expect(result.current).toHaveLength(0) + }) + + it('multiple entries accumulate in order', () => { + const { result } = renderHook(() => useGraphQLRequests()) + act(() => { + mock.fire(makeGraphQLEntry()) + mock.fire( + makeGraphQLEntry({ + postData: { + text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }), + }, + }) + ) + }) + expect(result.current).toHaveLength(2) + expect(result.current[0].operationName).toBe('GetHero') + expect(result.current[1].operationName).toBe('CreateUser') + }) + + it('id increments monotonically across multiple entries', () => { + const { result } = renderHook(() => useGraphQLRequests()) + act(() => { + mock.fire(makeGraphQLEntry()) + mock.fire(makeGraphQLEntry()) + mock.fire(makeGraphQLEntry()) + }) + expect(result.current.map((r) => r.id)).toEqual(['1', '2', '3']) + }) +}) diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts new file mode 100644 index 0000000..f75f8ed --- /dev/null +++ b/entrypoints/devtools-panel/har.ts @@ -0,0 +1,99 @@ +import { parse, OperationDefinitionNode } from 'graphql' + +export type HAREntry = { + request: { + method: string + url: string + headers: Array<{ name: string; value: string }> + postData?: { text?: string } + } + response: { + status: number + content: { size: number } + } + time: number +} + +export type GraphQLRequest = { + id: string + operationName: string + status: number + size: number + time: number + url: string +} + +export function isGraphQLEntry(entry: HAREntry): boolean { + const { method, url, headers, postData } = entry.request + + if (method === 'POST') { + const contentType = headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? '' + if (!contentType.includes('application/json')) return false + if (!postData?.text) return false + try { + const body = JSON.parse(postData.text) + return typeof body.query === 'string' + } catch { + return false + } + } + + if (method === 'GET') { + try { + return new URL(url).searchParams.has('query') + } catch { + return false + } + } + + return false +} + +export function extractOperationName(entry: HAREntry): string { + const { method, url, postData } = entry.request + + if (method === 'POST' && postData?.text) { + try { + const body = JSON.parse(postData.text) + if (typeof body.operationName === 'string' && body.operationName.trim()) { + return body.operationName.trim() + } + if (typeof body.query === 'string') { + return nameFromQuery(body.query) + } + } catch { + // fall through + } + } + + if (method === 'GET') { + try { + const params = new URL(url).searchParams + const opName = params.get('operationName') + if (opName?.trim()) return opName.trim() + const query = params.get('query') + if (query) return nameFromQuery(query) + } catch { + // fall through + } + } + + return 'Anonymous' +} + +function nameFromQuery(query: string): string { + try { + const ast = parse(query) + const op = ast.definitions.find( + (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition' + ) + if (op?.name?.value) return op.name.value + if (op?.operation) { + const t = op.operation + return t.charAt(0).toUpperCase() + t.slice(1) + } + } catch { + // fall through + } + return 'Anonymous' +} diff --git a/entrypoints/devtools-panel/index.html b/entrypoints/devtools-panel/index.html new file mode 100644 index 0000000..3aa6393 --- /dev/null +++ b/entrypoints/devtools-panel/index.html @@ -0,0 +1,12 @@ + + + + + + GraphiTab DevTools + + +
+ + + diff --git a/entrypoints/devtools-panel/main.tsx b/entrypoints/devtools-panel/main.tsx new file mode 100644 index 0000000..8ce25f6 --- /dev/null +++ b/entrypoints/devtools-panel/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App.tsx' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts new file mode 100644 index 0000000..35b5f6e --- /dev/null +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -0,0 +1,32 @@ +import { useState, useEffect } from 'react' + +import { isGraphQLEntry, extractOperationName } from './har' +import type { HAREntry, GraphQLRequest } from './har' + +export function useGraphQLRequests(): GraphQLRequest[] { + const [requests, setRequests] = useState([]) + + useEffect(() => { + let counter = 0 + function handleRequest(entry: HAREntry) { + if (!isGraphQLEntry(entry)) return + setRequests((prev) => [ + ...prev, + { + id: String(++counter), + operationName: extractOperationName(entry), + status: entry.response.status, + size: entry.response.content.size, + time: entry.time, + url: entry.request.url, + }, + ]) + } + chrome.devtools.network.onRequestFinished.addListener(handleRequest) + return () => { + chrome.devtools.network.onRequestFinished.removeListener(handleRequest) + } + }, []) + + return requests +} diff --git a/entrypoints/devtools/__tests__/main.test.ts b/entrypoints/devtools/__tests__/main.test.ts new file mode 100644 index 0000000..3e11dd8 --- /dev/null +++ b/entrypoints/devtools/__tests__/main.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fakeBrowser } from 'wxt/testing' + +describe('devtools/main', () => { + beforeEach(() => { + fakeBrowser.reset() + vi.resetModules() + }) + + it('registers the GraphiTab devtools panel', async () => { + const mockCreate = vi.spyOn(fakeBrowser.devtools.panels, 'create').mockResolvedValue({} as any) + + await import('../main') + + expect(mockCreate).toHaveBeenCalledWith('GraphiTab', '', 'devtools-panel.html') + }) +}) diff --git a/entrypoints/devtools/index.html b/entrypoints/devtools/index.html new file mode 100644 index 0000000..0ceb7ec --- /dev/null +++ b/entrypoints/devtools/index.html @@ -0,0 +1,10 @@ + + + + + GraphiTab DevTools + + + + + diff --git a/entrypoints/devtools/main.ts b/entrypoints/devtools/main.ts new file mode 100644 index 0000000..aad7f7a --- /dev/null +++ b/entrypoints/devtools/main.ts @@ -0,0 +1,3 @@ +browser.devtools.panels.create('GraphiTab', '', 'devtools-panel.html') + +export {} diff --git a/package.json b/package.json index bc35ad6..8042762 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,11 @@ "@graphiql/plugin-explorer": "^5.1.1", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", + "filesize": "^11.0.13", "graphiql": "^5.2.2", "graphql-ws": "^6.0.7", + "graphql": "^16.13.0", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "uuid": "^13.0.0" @@ -44,6 +47,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/chrome": "^0.1.37", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.0.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5216bb..efa5ede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,19 +10,28 @@ importers: dependencies: '@graphiql/plugin-explorer': specifier: ^5.1.1 - version: 5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@graphiql/react': specifier: ^0.37.3 - version: 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@graphiql/toolkit': specifier: ^0.11.3 - version: 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0) + version: 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) + filesize: + specifier: ^11.0.13 + version: 11.0.13 graphiql: specifier: ^5.2.2 - version: 5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphql: + specifier: ^16.13.0 + version: 16.13.0 graphql-ws: specifier: ^6.0.7 - version: 6.0.7(graphql@16.12.0) + version: 6.0.7(graphql@16.13.0) + pretty-ms: + specifier: ^9.3.0 + version: 9.3.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -45,6 +54,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/chrome': + specifier: ^0.1.37 + version: 0.1.37 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -1523,6 +1535,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.1.37': + resolution: {integrity: sha512-IJE4ceuDO7lrEuua7Pow47zwNcI8E6qqkowRP7aFPaZ0lrjxh6y836OPqqkIZeTX64FTogbw+4RNH0+QrweCTQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2235,8 +2250,8 @@ packages: ws: optional: true - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.13.0: + resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} growly@1.3.0: @@ -2739,6 +2754,10 @@ packages: resolution: {integrity: sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==} engines: {node: '>=16'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -2806,6 +2825,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -3806,11 +3829,11 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - graphql: 16.12.0 + graphql: 16.13.0 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -3820,18 +3843,18 @@ snapshots: - immer - use-sync-external-store - '@graphiql/plugin-explorer@5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@graphiql/plugin-explorer@5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphiql-explorer: 0.9.0(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - graphql: 16.12.0 + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphiql-explorer: 0.9.0(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + graphql: 16.13.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -3844,9 +3867,9 @@ snapshots: - immer - use-sync-external-store - '@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0) + '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3854,12 +3877,12 @@ snapshots: clsx: 1.2.1 framer-motion: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) get-value: 3.0.1 - graphql: 16.12.0 - graphql-language-service: 5.5.0(graphql@16.12.0) + graphql: 16.13.0 + graphql-language-service: 5.5.0(graphql@16.13.0) jsonc-parser: 3.3.1 markdown-it: 14.1.1 monaco-editor: 0.52.2 - monaco-graphql: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1) + monaco-graphql: 1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1) prettier: 3.8.1 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) @@ -3875,13 +3898,13 @@ snapshots: - immer - use-sync-external-store - '@graphiql/toolkit@0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)': + '@graphiql/toolkit@0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)': dependencies: '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 - graphql: 16.12.0 + graphql: 16.13.0 meros: 1.3.2(@types/node@25.3.5) optionalDependencies: - graphql-ws: 6.0.7(graphql@16.12.0) + graphql-ws: 6.0.7(graphql@16.13.0) transitivePeerDependencies: - '@types/node' @@ -4672,6 +4695,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.1.37': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5318,18 +5346,18 @@ snapshots: graceful-readlink@1.0.1: {} - graphiql-explorer@0.9.0(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + graphiql-explorer@0.9.0(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - graphql: 16.12.0 + graphql: 16.13.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - graphiql@5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + graphiql@5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: - '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.12.0))(graphql@16.12.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphql: 16.12.0 + '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphql: 16.13.0 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -5342,18 +5370,18 @@ snapshots: - immer - use-sync-external-store - graphql-language-service@5.5.0(graphql@16.12.0): + graphql-language-service@5.5.0(graphql@16.13.0): dependencies: debounce-promise: 3.1.2 - graphql: 16.12.0 + graphql: 16.13.0 nullthrows: 1.1.1 vscode-languageserver-types: 3.17.5 - graphql-ws@6.0.7(graphql@16.12.0): + graphql-ws@6.0.7(graphql@16.13.0): dependencies: - graphql: 16.12.0 + graphql: 16.13.0 - graphql@16.12.0: {} + graphql@16.13.0: {} growly@1.3.0: {} @@ -5721,10 +5749,10 @@ snapshots: monaco-editor@0.52.2: {} - monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1): + monaco-graphql@1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1): dependencies: - graphql: 16.12.0 - graphql-language-service: 5.5.0(graphql@16.12.0) + graphql: 16.13.0 + graphql-language-service: 5.5.0(graphql@16.13.0) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 prettier: 3.8.1 @@ -5884,6 +5912,8 @@ snapshots: lines-and-columns: 2.0.4 type-fest: 3.13.1 + parse-ms@4.0.0: {} + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -5956,6 +5986,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} From 3948a1afdba0057f682deafca0fdae58d37277dc Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Sun, 1 Mar 2026 16:07:33 -0500 Subject: [PATCH 02/30] Some visual indicators for the table --- entrypoints/devtools-panel/App.css | 52 ++++++ entrypoints/devtools-panel/App.tsx | 18 +- .../devtools-panel/__tests__/App.test.tsx | 49 +++++ .../devtools-panel/__tests__/har.test.ts | 169 +++++++++++------- .../__tests__/useGraphQLRequests.test.ts | 8 +- entrypoints/devtools-panel/har.ts | 45 +++-- .../devtools-panel/useGraphQLRequests.ts | 6 +- 7 files changed, 264 insertions(+), 83 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index e10aac7..9588e0e 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -67,6 +67,58 @@ body { cursor: pointer; } +.gt-status-dot { + display: inline-block; + width: 0.5em; + height: 0.5em; + border-radius: 50%; + margin-right: var(--px-6); + vertical-align: middle; + flex-shrink: 0; +} + +.gt-status-dot--success { + background-color: #4caf50; +} + +.gt-status-dot--error { + background-color: hsl(var(--color-error)); +} + +.gt-op-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25em; + height: 1.25em; + border-radius: var(--border-radius-4); + font-size: 0.7em; + font-weight: 700; + line-height: 1; + margin-right: var(--px-6); + flex-shrink: 0; + color: white; + vertical-align: middle; +} + +.gt-op-badge--query { + background-color: hsl(var(--color-primary)); +} + +.gt-op-badge--mutation { + background-color: hsl(var(--color-error)); +} + +.gt-op-badge--subscription { + background-color: hsl(var(--color-primary)); + opacity: 0.65; +} + +.gt-op-badge--unknown { + background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); + color: hsl(var(--color-neutral)); +} + .gt-network-empty td { text-align: center; color: hsla(var(--color-neutral), var(--alpha-tertiary)); diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index a49f343..7244263 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -29,8 +29,22 @@ export default function App() { ) : ( requests.map((req) => ( - {req.operationName} - {req.status} + + + {req.operationType === 'mutation' + ? 'M' + : req.operationType === 'subscription' + ? 'S' + : 'Q'} + + {req.operationName} + + + + {req.status} + {filesize(req.size)} {prettyMs(req.time)} {req.url} diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index fb396bf..a3c09b6 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -17,6 +17,7 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { return { id: '1', operationName: 'GetHero', + operationType: 'query', status: 200, size: 512, time: 123, @@ -80,4 +81,52 @@ describe('DevTools Panel App', () => { render() expect(screen.getByText('123ms')).toBeInTheDocument() }) + + it('shows Q badge for query operations', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'query' })]) + render() + const badge = screen.getByText('Q') + expect(badge).toHaveClass('gt-op-badge--query') + }) + + it('shows M badge for mutation operations', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'mutation' })]) + render() + const badge = screen.getByText('M') + expect(badge).toHaveClass('gt-op-badge--mutation') + }) + + it('shows S badge for subscription operations', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'subscription' })]) + render() + const badge = screen.getByText('S') + expect(badge).toHaveClass('gt-op-badge--subscription') + }) + + it('shows Q badge for unknown operations', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'unknown' })]) + render() + const badge = screen.getByText('Q') + expect(badge).toHaveClass('gt-op-badge--unknown') + }) + + it('shows success dot for 2xx status', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 200 })]) + const { container } = render() + expect(container.querySelector('.gt-status-dot--success')).toBeInTheDocument() + expect(container.querySelector('.gt-status-dot--error')).not.toBeInTheDocument() + }) + + it('shows error dot for 4xx status', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 400 })]) + const { container } = render() + expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() + expect(container.querySelector('.gt-status-dot--success')).not.toBeInTheDocument() + }) + + it('shows error dot for 5xx status', () => { + mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 500 })]) + const { container } = render() + expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts index 00edccc..8734ab6 100644 --- a/entrypoints/devtools-panel/__tests__/har.test.ts +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { isGraphQLEntry, extractOperationName } from '../har' +import { isGraphQLEntry, extractOperationInfo } from '../har' import type { HAREntry } from '../har' function makeEntry(overrides: Partial = {}): HAREntry { @@ -143,72 +143,87 @@ describe('isGraphQLEntry', () => { }) }) -describe('extractOperationName', () => { - it('POST: explicit operationName field → returned trimmed', () => { +describe('extractOperationInfo', () => { + it('POST: named query → name and type from AST', () => { const entry = makeEntry({ request: { method: 'POST', url: 'https://example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], - postData: { - text: JSON.stringify({ operationName: ' GetHero ', query: 'query GetHero { hero }' }), - }, + postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, }, }) - expect(extractOperationName(entry)).toBe('GetHero') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'GetHero', + operationType: 'query', + }) }) - it('POST: blank operationName falls through to query parsing', () => { + it('POST: named mutation → name and type from AST', () => { const entry = makeEntry({ request: { method: 'POST', url: 'https://example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], - postData: { - text: JSON.stringify({ operationName: ' ', query: 'query MyQuery { hero }' }), - }, + postData: { text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }) }, }, }) - expect(extractOperationName(entry)).toBe('MyQuery') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'CreateUser', + operationType: 'mutation', + }) }) - it('POST: named query operation → name from AST', () => { + it('POST: named subscription → name and type from AST', () => { const entry = makeEntry({ request: { method: 'POST', url: 'https://example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], - postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, + postData: { text: JSON.stringify({ query: 'subscription OnMessage { message { id } }' }) }, }, }) - expect(extractOperationName(entry)).toBe('GetHero') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'OnMessage', + operationType: 'subscription', + }) }) - it('POST: named mutation operation → name from AST', () => { + it('POST: explicit operationName overrides AST name, type still from AST', () => { const entry = makeEntry({ request: { method: 'POST', url: 'https://example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], - postData: { text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }) }, + postData: { + text: JSON.stringify({ operationName: ' GetHero ', query: 'query GetHero { hero }' }), + }, }, }) - expect(extractOperationName(entry)).toBe('CreateUser') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'GetHero', + operationType: 'query', + }) }) - it('POST: named subscription operation → name from AST', () => { + it('POST: blank operationName falls through to AST name', () => { const entry = makeEntry({ request: { method: 'POST', url: 'https://example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], - postData: { text: JSON.stringify({ query: 'subscription OnMessage { message { id } }' }) }, + postData: { + text: JSON.stringify({ operationName: ' ', query: 'query MyQuery { hero }' }), + }, }, }) - expect(extractOperationName(entry)).toBe('OnMessage') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'MyQuery', + operationType: 'query', + }) }) - it('POST: anonymous operation with keyword → capitalized type', () => { + it('POST: anonymous mutation → capitalized type as name, mutation type', () => { const entry = makeEntry({ request: { method: 'POST', @@ -217,10 +232,13 @@ describe('extractOperationName', () => { postData: { text: JSON.stringify({ query: 'mutation { createUser { id } }' }) }, }, }) - expect(extractOperationName(entry)).toBe('Mutation') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Mutation', + operationType: 'mutation', + }) }) - it('POST: shorthand query → "Query"', () => { + it('POST: shorthand query → "Query" name, query type', () => { const entry = makeEntry({ request: { method: 'POST', @@ -229,10 +247,10 @@ describe('extractOperationName', () => { postData: { text: JSON.stringify({ query: '{ hero }' }) }, }, }) - expect(extractOperationName(entry)).toBe('Query') + expect(extractOperationInfo(entry)).toEqual({ operationName: 'Query', operationType: 'query' }) }) - it('POST: invalid query string → "Anonymous"', () => { + it('POST: invalid query string → Anonymous, unknown', () => { const entry = makeEntry({ request: { method: 'POST', @@ -241,10 +259,13 @@ describe('extractOperationName', () => { postData: { text: JSON.stringify({ query: '!@#invalid' }) }, }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('POST: no body → "Anonymous"', () => { + it('POST: no body → Anonymous, unknown', () => { const entry = makeEntry({ request: { method: 'POST', @@ -252,10 +273,13 @@ describe('extractOperationName', () => { headers: [{ name: 'content-type', value: 'application/json' }], }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('POST: invalid JSON → "Anonymous"', () => { + it('POST: invalid JSON → Anonymous, unknown', () => { const entry = makeEntry({ request: { method: 'POST', @@ -264,34 +288,44 @@ describe('extractOperationName', () => { postData: { text: 'not-json' }, }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('GET: operationName param present → returned trimmed', () => { + it('POST: body has no query field → Anonymous, unknown', () => { const entry = makeEntry({ request: { - method: 'GET', - url: 'https://example.com/graphql?query=%7B%20hero%20%7D&operationName=GetHero', - headers: [], + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ variables: { id: '1' } }) }, }, }) - expect(extractOperationName(entry)).toBe('GetHero') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('GET: no operationName, query param with named op → name from AST', () => { - const query = encodeURIComponent('query GetHero { hero { name } }') + it('POST: fragment-only document → Anonymous, unknown', () => { const entry = makeEntry({ request: { - method: 'GET', - url: `https://example.com/graphql?query=${query}`, - headers: [], + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'fragment F on Query { hero { name } }' }) }, }, }) - expect(extractOperationName(entry)).toBe('GetHero') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('GET: no operationName, shorthand query → "Query"', () => { - const query = encodeURIComponent('{ hero }') + it('GET: named query param → name and type from AST', () => { + const query = encodeURIComponent('query GetHero { hero { name } }') const entry = makeEntry({ request: { method: 'GET', @@ -299,33 +333,39 @@ describe('extractOperationName', () => { headers: [], }, }) - expect(extractOperationName(entry)).toBe('Query') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'GetHero', + operationType: 'query', + }) }) - it('GET: malformed URL → "Anonymous"', () => { + it('GET: operationName param overrides AST name, type still from AST', () => { const entry = makeEntry({ request: { method: 'GET', - url: 'not a url', + url: 'https://example.com/graphql?query=%7B%20hero%20%7D&operationName=GetHero', headers: [], }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'GetHero', + operationType: 'query', + }) }) - it('POST: body has no query field (non-string query) → "Anonymous"', () => { + it('GET: shorthand query with no operationName → "Query", query type', () => { + const query = encodeURIComponent('{ hero }') const entry = makeEntry({ request: { - method: 'POST', - url: 'https://example.com/graphql', - headers: [{ name: 'content-type', value: 'application/json' }], - postData: { text: JSON.stringify({ variables: { id: '1' } }) }, + method: 'GET', + url: `https://example.com/graphql?query=${query}`, + headers: [], }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ operationName: 'Query', operationType: 'query' }) }) - it('GET: valid URL with no query param → "Anonymous"', () => { + it('GET: valid URL with no query param → Anonymous, unknown', () => { const entry = makeEntry({ request: { method: 'GET', @@ -333,18 +373,23 @@ describe('extractOperationName', () => { headers: [], }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) - it('fragment-only document (no OperationDefinition) → "Anonymous"', () => { + it('GET: malformed URL → Anonymous, unknown', () => { const entry = makeEntry({ request: { - method: 'POST', - url: 'https://example.com/graphql', - headers: [{ name: 'content-type', value: 'application/json' }], - postData: { text: JSON.stringify({ query: 'fragment F on Query { hero { name } }' }) }, + method: 'GET', + url: 'not a url', + headers: [], }, }) - expect(extractOperationName(entry)).toBe('Anonymous') + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Anonymous', + operationType: 'unknown', + }) }) }) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index 1d8dbd5..d00c375 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -95,6 +95,7 @@ describe('useGraphQLRequests', () => { expect(result.current[0]).toMatchObject({ id: '1', operationName: 'GetHero', + operationType: 'query', status: 200, size: 512, time: 123, @@ -123,8 +124,11 @@ describe('useGraphQLRequests', () => { ) }) expect(result.current).toHaveLength(2) - expect(result.current[0].operationName).toBe('GetHero') - expect(result.current[1].operationName).toBe('CreateUser') + expect(result.current[0]).toMatchObject({ operationName: 'GetHero', operationType: 'query' }) + expect(result.current[1]).toMatchObject({ + operationName: 'CreateUser', + operationType: 'mutation', + }) }) it('id increments monotonically across multiple entries', () => { diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index f75f8ed..1c334e7 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -14,9 +14,12 @@ export type HAREntry = { time: number } +export type OperationType = 'query' | 'mutation' | 'subscription' | 'unknown' + export type GraphQLRequest = { id: string operationName: string + operationType: OperationType status: number size: number time: number @@ -49,17 +52,23 @@ export function isGraphQLEntry(entry: HAREntry): boolean { return false } -export function extractOperationName(entry: HAREntry): string { +export type OperationInfo = { + operationName: string + operationType: OperationType +} + +export function extractOperationInfo(entry: HAREntry): OperationInfo { const { method, url, postData } = entry.request if (method === 'POST' && postData?.text) { try { const body = JSON.parse(postData.text) - if (typeof body.operationName === 'string' && body.operationName.trim()) { - return body.operationName.trim() - } if (typeof body.query === 'string') { - return nameFromQuery(body.query) + const info = parseOperation(body.query) + if (typeof body.operationName === 'string' && body.operationName.trim()) { + return { operationName: body.operationName.trim(), operationType: info.operationType } + } + return info } } catch { // fall through @@ -69,31 +78,37 @@ export function extractOperationName(entry: HAREntry): string { if (method === 'GET') { try { const params = new URL(url).searchParams - const opName = params.get('operationName') - if (opName?.trim()) return opName.trim() const query = params.get('query') - if (query) return nameFromQuery(query) + if (query) { + const info = parseOperation(query) + const opName = params.get('operationName') + if (opName?.trim()) { + return { operationName: opName.trim(), operationType: info.operationType } + } + return info + } } catch { // fall through } } - return 'Anonymous' + return { operationName: 'Anonymous', operationType: 'unknown' } } -function nameFromQuery(query: string): string { +function parseOperation(query: string): OperationInfo { try { const ast = parse(query) const op = ast.definitions.find( (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition' ) - if (op?.name?.value) return op.name.value - if (op?.operation) { - const t = op.operation - return t.charAt(0).toUpperCase() + t.slice(1) + if (op) { + const operationType = op.operation + const operationName = + op.name?.value ?? op.operation.charAt(0).toUpperCase() + op.operation.slice(1) + return { operationName, operationType } } } catch { // fall through } - return 'Anonymous' + return { operationName: 'Anonymous', operationType: 'unknown' } } diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index 35b5f6e..2750295 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import { isGraphQLEntry, extractOperationName } from './har' +import { isGraphQLEntry, extractOperationInfo } from './har' import type { HAREntry, GraphQLRequest } from './har' export function useGraphQLRequests(): GraphQLRequest[] { @@ -10,11 +10,13 @@ export function useGraphQLRequests(): GraphQLRequest[] { let counter = 0 function handleRequest(entry: HAREntry) { if (!isGraphQLEntry(entry)) return + const { operationName, operationType } = extractOperationInfo(entry) setRequests((prev) => [ ...prev, { id: String(++counter), - operationName: extractOperationName(entry), + operationName, + operationType, status: entry.response.status, size: entry.response.content.size, time: entry.time, From 65c9264dbc85bf472506008729832c604f125b01 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Sun, 1 Mar 2026 16:24:45 -0500 Subject: [PATCH 03/30] Some controls for clearing and filtering the table --- entrypoints/devtools-panel/App.css | 39 +++++ entrypoints/devtools-panel/App.tsx | 56 +++++++- .../devtools-panel/__tests__/App.test.tsx | 99 +++++++++++-- .../__tests__/useGraphQLRequests.test.ts | 133 ++++++++++++++---- .../devtools-panel/useGraphQLRequests.ts | 20 ++- 5 files changed, 305 insertions(+), 42 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index 9588e0e..0aac718 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -124,3 +124,42 @@ body { color: hsla(var(--color-neutral), var(--alpha-tertiary)); padding: var(--px-16); } + +.gt-devtools-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--px-6) var(--px-8); + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + gap: var(--px-8); + flex-shrink: 0; +} + +.gt-devtools-toolbar-controls { + display: flex; + align-items: center; + gap: var(--px-8); +} + +.gt-toolbar-label { + display: flex; + align-items: center; + gap: var(--px-6); + font-family: var(--font-family); + font-size: var(--font-size-body); + cursor: pointer; + white-space: nowrap; +} + +.gt-type-filter { + display: flex; + gap: var(--px-4); +} + +.gt-type-filter-btn { + opacity: 0.45; +} + +.gt-type-filter-btn--active { + opacity: 1; +} diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index 7244263..c7a3bca 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -1,16 +1,66 @@ import { filesize } from 'filesize' import prettyMs from 'pretty-ms' +import { useState } from 'react' import 'graphiql/style.css' import './App.css' +import type { OperationType } from './har' import { useGraphQLRequests } from './useGraphQLRequests' +const FILTER_TYPES: OperationType[] = ['query', 'mutation', 'subscription'] + export default function App() { - const requests = useGraphQLRequests() + const [autoClear, setAutoClear] = useState(false) + const [activeTypes, setActiveTypes] = useState>(new Set(FILTER_TYPES)) + + const { requests, clear } = useGraphQLRequests(autoClear) + + function toggleType(type: OperationType) { + setActiveTypes((prev) => { + const next = new Set(prev) + if (next.has(type)) { + next.delete(type) + } else { + next.add(type) + } + return next + }) + } + + const visible = requests.filter( + (r) => r.operationType === 'unknown' || activeTypes.has(r.operationType) + ) return (
+
+
+ + +
+
+ {FILTER_TYPES.map((type) => ( + + ))} +
+
@@ -22,12 +72,12 @@ export default function App() { - {requests.length === 0 ? ( + {visible.length === 0 ? ( ) : ( - requests.map((req) => ( + visible.map((req) => ( ))} diff --git a/entrypoints/devtools-panel/RequestTab.tsx b/entrypoints/devtools-panel/RequestTab.tsx index 2102e66..2176250 100644 --- a/entrypoints/devtools-panel/RequestTab.tsx +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -5,6 +5,7 @@ import graphql from 'highlight.js/lib/languages/graphql' import { useMemo, useState } from 'react' import './RequestTab.css' +import { CopyButton } from './CopyButton' import type { GraphQLRequest } from './har' hljs.registerLanguage('graphql', graphql) @@ -27,27 +28,6 @@ function parseJsonObject(str: string): Record | null { } } -function copyText(text: string) { - navigator.clipboard.writeText(text).catch(() => {}) -} - -const CopyIcon = () => ( - -) - type Props = { request: GraphQLRequest } @@ -84,17 +64,12 @@ export function RequestTab({ request }: Props) { Query - +
           {showRaw ? (
@@ -109,13 +84,7 @@ export function RequestTab({ request }: Props) {
         

Variables - +

{parsedVariables ? (
@@ -140,13 +109,7 @@ export function RequestTab({ request }: Props) {

Extensions - +

{parsedExtensions ? (
diff --git a/entrypoints/devtools-panel/ResponseTab.tsx b/entrypoints/devtools-panel/ResponseTab.tsx index 6ce692f..46b2a80 100644 --- a/entrypoints/devtools-panel/ResponseTab.tsx +++ b/entrypoints/devtools-panel/ResponseTab.tsx @@ -2,6 +2,7 @@ import ReactJsonView from '@microlink/react-json-view' import { useMemo, useState } from 'react' import './ResponseTab.css' +import { CopyButton } from './CopyButton' import type { GraphQLRequest } from './har' function parseJsonObject(str: string): Record | null { @@ -14,27 +15,6 @@ function parseJsonObject(str: string): Record | null { } } -function copyText(text: string) { - navigator.clipboard.writeText(text).catch(() => {}) -} - -const CopyIcon = () => ( - -) - type Props = { request: GraphQLRequest } @@ -71,19 +51,15 @@ export function ResponseTab({ request }: Props) { Body - + /> {showRaw || !parsedResponse ? (
diff --git a/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx
new file mode 100644
index 0000000..2a91cc9
--- /dev/null
+++ b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx
@@ -0,0 +1,85 @@
+import { cleanup, render, screen, fireEvent, act } from '@testing-library/react'
+// @vitest-environment jsdom
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
+import '@testing-library/jest-dom/vitest'
+
+import { CopyButton } from '../CopyButton'
+
+describe('CopyButton', () => {
+  beforeEach(() => {
+    vi.useFakeTimers()
+    Object.assign(navigator, {
+      clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
+    })
+  })
+
+  afterEach(() => {
+    cleanup()
+    vi.useRealTimers()
+    vi.restoreAllMocks()
+  })
+
+  it('renders with the given title', () => {
+    render()
+    expect(screen.getByTitle('Copy value')).toBeInTheDocument()
+  })
+
+  it('shows the copy icon by default', () => {
+    render()
+    expect(screen.getByTestId('copy-icon')).toBeInTheDocument()
+    expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
+  })
+
+  it('writes the text to clipboard when clicked', () => {
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello world')
+  })
+
+  it('shows the check icon immediately after clicking', () => {
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    expect(screen.getByTestId('check-icon')).toBeInTheDocument()
+    expect(screen.queryByTestId('copy-icon')).not.toBeInTheDocument()
+  })
+
+  it('reverts to the copy icon after 1500ms', () => {
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    expect(screen.getByTestId('check-icon')).toBeInTheDocument()
+    act(() => vi.advanceTimersByTime(1500))
+    expect(screen.getByTestId('copy-icon')).toBeInTheDocument()
+    expect(screen.queryByTestId('check-icon')).not.toBeInTheDocument()
+  })
+
+  it('does not revert before 1500ms', () => {
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    act(() => vi.advanceTimersByTime(1499))
+    expect(screen.getByTestId('check-icon')).toBeInTheDocument()
+  })
+
+  it('resets the timer if clicked again while showing check icon', () => {
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    act(() => vi.advanceTimersByTime(1000))
+    fireEvent.click(screen.getByTitle('Copy value'))
+    act(() => vi.advanceTimersByTime(1000))
+    // 1000ms after the second click — should still show check
+    expect(screen.getByTestId('check-icon')).toBeInTheDocument()
+    act(() => vi.advanceTimersByTime(500))
+    // 1500ms after second click — should revert
+    expect(screen.getByTestId('copy-icon')).toBeInTheDocument()
+  })
+
+  it('applies the default gt-headers-copy-btn class', () => {
+    render()
+    expect(screen.getByTitle('Copy value')).toHaveClass('gt-headers-copy-btn')
+  })
+
+  it('applies a custom className when provided', () => {
+    render()
+    expect(screen.getByTitle('Copy value')).toHaveClass('my-btn')
+    expect(screen.getByTitle('Copy value')).not.toHaveClass('gt-headers-copy-btn')
+  })
+})
diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
index 43af78e..64f130f 100644
--- a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
@@ -89,7 +89,9 @@ describe('RequestTab', () => {
 
   it('renders a "Raw" toggle button in the Query section', () => {
     render()
-    expect(screen.getByRole('button', { name: 'Raw' })).toBeInTheDocument()
+    const btn = screen.getByRole('button', { name: 'Raw' })
+    expect(btn).toBeInTheDocument()
+    expect(btn).toHaveAttribute('title', 'Display original, unformatted value')
   })
 
   it('Raw toggle is inactive by default', () => {
diff --git a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
index b83b710..3b49256 100644
--- a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
@@ -125,7 +125,9 @@ describe('ResponseTab', () => {
 
   it('Raw toggle button is present when response is present', () => {
     render()
-    expect(screen.getByRole('button', { name: 'Raw' })).toBeInTheDocument()
+    const btn = screen.getByRole('button', { name: 'Raw' })
+    expect(btn).toBeInTheDocument()
+    expect(btn).toHaveAttribute('title', 'Display original, unformatted value')
   })
 
   it('Raw toggle is inactive by default', () => {
diff --git a/entrypoints/devtools-panel/tab-shared.css b/entrypoints/devtools-panel/tab-shared.css
index 9ddc2e9..0264479 100644
--- a/entrypoints/devtools-panel/tab-shared.css
+++ b/entrypoints/devtools-panel/tab-shared.css
@@ -6,8 +6,8 @@
   color: hsl(var(--color-neutral));
   font-family: var(--font-family);
   font-size: var(--font-size-body);
-  overflow-x: auto;
-  white-space: pre;
+  white-space: pre-wrap;
+  overflow-wrap: break-word;
 }
 
 /* Raw toggle button */

From 8ff55aff0e99788f215a528c785e2736ed1f5d21 Mon Sep 17 00:00:00 2001
From: Jason Simeone 
Date: Wed, 4 Mar 2026 00:28:08 -0500
Subject: [PATCH 15/30] Improve tests

---
 .../devtools-panel/__tests__/App.test.tsx     | 609 +++++++++---------
 .../__tests__/ContextMenu.test.tsx            |   7 +
 .../__tests__/CopyButton.test.tsx             |   9 +-
 .../__tests__/RequestModal.test.tsx           |  91 +--
 .../__tests__/RequestTab.test.tsx             | 303 +++++----
 .../__tests__/ResponseTab.test.tsx            | 199 +++---
 6 files changed, 625 insertions(+), 593 deletions(-)

diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx
index f914a58..530f1d1 100644
--- a/entrypoints/devtools-panel/__tests__/App.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/App.test.tsx
@@ -61,349 +61,336 @@ describe('DevTools Panel App', () => {
     cleanup()
   })
 
-  // ---------------------------------------------------------------------------
-  // Column headers
-  // ---------------------------------------------------------------------------
-
-  it('renders all 5 column headers', () => {
-    mockHook([])
-    render()
-    expect(screen.getByText('Operation')).toBeInTheDocument()
-    expect(screen.getByText('Status')).toBeInTheDocument()
-    expect(screen.getByText('Size')).toBeInTheDocument()
-    expect(screen.getByText('Time')).toBeInTheDocument()
-    expect(screen.getByText('URL')).toBeInTheDocument()
+  describe('Column headers', () => {
+    it('renders all 5 column headers', () => {
+      mockHook([])
+      render()
+      expect(screen.getByText('Operation')).toBeInTheDocument()
+      expect(screen.getByText('Status')).toBeInTheDocument()
+      expect(screen.getByText('Size')).toBeInTheDocument()
+      expect(screen.getByText('Time')).toBeInTheDocument()
+      expect(screen.getByText('URL')).toBeInTheDocument()
+    })
   })
 
-  // ---------------------------------------------------------------------------
-  // Row rendering
-  // ---------------------------------------------------------------------------
-
-  it('shows empty state when there are no requests', () => {
-    mockHook([])
-    render()
-    expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument()
+  describe('Row rendering', () => {
+    it('shows empty state when there are no requests', () => {
+      mockHook([])
+      render()
+      expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument()
+    })
+
+    it('renders one row per visible request', () => {
+      mockHook([
+        makeRequest({ id: '1', operationName: 'GetHero' }),
+        makeRequest({ id: '2', operationName: 'CreateUser', operationType: 'mutation' }),
+        makeRequest({ id: '3', operationName: 'OnUpdate', operationType: 'subscription' }),
+      ])
+      render()
+      expect(document.querySelectorAll('.gt-network-row')).toHaveLength(3)
+    })
+
+    it('renders operation name, status, and URL for each row', () => {
+      mockHook([
+        makeRequest({
+          id: '1',
+          operationName: 'GetHero',
+          status: 200,
+          url: 'https://a.example.com/graphql',
+        }),
+        makeRequest({
+          id: '2',
+          operationName: 'CreateUser',
+          status: 201,
+          url: 'https://b.example.com/graphql',
+        }),
+      ])
+      render()
+      expect(screen.getByText('GetHero')).toBeInTheDocument()
+      expect(screen.getByText('CreateUser')).toBeInTheDocument()
+      expect(screen.getByText('200')).toBeInTheDocument()
+      expect(screen.getByText('201')).toBeInTheDocument()
+      expect(screen.getByText('https://a.example.com/graphql')).toBeInTheDocument()
+      expect(screen.getByText('https://b.example.com/graphql')).toBeInTheDocument()
+    })
+
+    it('shows success status dot for 2xx responses and error dot for 4xx/5xx', () => {
+      mockHook([makeRequest({ id: '1', status: 200 }), makeRequest({ id: '2', status: 500 })])
+      const { container } = render()
+      expect(container.querySelectorAll('.gt-status-dot--success')).toHaveLength(1)
+      expect(container.querySelectorAll('.gt-status-dot--error')).toHaveLength(1)
+    })
   })
 
-  it('renders one row per visible request', () => {
-    mockHook([
-      makeRequest({ id: '1', operationName: 'GetHero' }),
-      makeRequest({ id: '2', operationName: 'CreateUser', operationType: 'mutation' }),
-      makeRequest({ id: '3', operationName: 'OnUpdate', operationType: 'subscription' }),
-    ])
-    render()
-    expect(document.querySelectorAll('.gt-network-row')).toHaveLength(3)
+  describe('Clear button', () => {
+    it('Clear button calls clear() when clicked', async () => {
+      const clear = vi.fn()
+      mockHook([], clear)
+      render()
+      await userEvent.click(screen.getByRole('button', { name: 'Clear network log' }))
+      expect(clear).toHaveBeenCalledOnce()
+    })
   })
 
-  it('renders operation name, status, and URL for each row', () => {
-    mockHook([
-      makeRequest({
-        id: '1',
-        operationName: 'GetHero',
-        status: 200,
-        url: 'https://a.example.com/graphql',
-      }),
-      makeRequest({
-        id: '2',
-        operationName: 'CreateUser',
-        status: 201,
-        url: 'https://b.example.com/graphql',
-      }),
-    ])
-    render()
-    expect(screen.getByText('GetHero')).toBeInTheDocument()
-    expect(screen.getByText('CreateUser')).toBeInTheDocument()
-    expect(screen.getByText('200')).toBeInTheDocument()
-    expect(screen.getByText('201')).toBeInTheDocument()
-    expect(screen.getByText('https://a.example.com/graphql')).toBeInTheDocument()
-    expect(screen.getByText('https://b.example.com/graphql')).toBeInTheDocument()
+  describe('Type filter', () => {
+    it('renders Query and Mutation filter buttons, both initially active', () => {
+      mockHook([])
+      render()
+      expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true')
+      expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute(
+        'aria-pressed',
+        'true'
+      )
+      expect(screen.queryByRole('button', { name: 'Subscription' })).not.toBeInTheDocument()
+      expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument()
+    })
+
+    it('deactivating Query hides query rows but keeps mutation rows', async () => {
+      mockHook([
+        makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
+        makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
+      ])
+      render()
+      await userEvent.click(screen.getByRole('button', { name: 'Query' }))
+      expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'false')
+      expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
+      expect(screen.getByText('CreateUser')).toBeInTheDocument()
+    })
+
+    it('deactivating Mutation hides mutation rows but keeps query rows', async () => {
+      mockHook([
+        makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
+        makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
+      ])
+      render()
+      await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
+      expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute(
+        'aria-pressed',
+        'false'
+      )
+      expect(screen.queryByText('CreateUser')).not.toBeInTheDocument()
+      expect(screen.getByText('GetHero')).toBeInTheDocument()
+    })
+
+    it('deactivating both filters hides all query/mutation rows and shows empty state', async () => {
+      mockHook([
+        makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
+        makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
+      ])
+      render()
+      await userEvent.click(screen.getByRole('button', { name: 'Query' }))
+      await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
+      expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
+      expect(screen.queryByText('CreateUser')).not.toBeInTheDocument()
+      expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument()
+    })
+
+    it('re-clicking a deactivated filter reactivates it and shows the rows again', async () => {
+      mockHook([makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' })])
+      render()
+      const queryBtn = screen.getByRole('button', { name: 'Query' })
+      await userEvent.click(queryBtn)
+      expect(queryBtn).toHaveAttribute('aria-pressed', 'false')
+      expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
+      await userEvent.click(queryBtn)
+      expect(queryBtn).toHaveAttribute('aria-pressed', 'true')
+      expect(screen.getByText('GetHero')).toBeInTheDocument()
+    })
+
+    it('subscription and unknown requests are always shown regardless of filter state', async () => {
+      mockHook([
+        makeRequest({ id: '1', operationType: 'subscription', operationName: 'OnUpdate' }),
+        makeRequest({ id: '2', operationType: 'unknown', operationName: 'Mystery' }),
+        makeRequest({ id: '3', operationType: 'query', operationName: 'GetHero' }),
+      ])
+      render()
+      await userEvent.click(screen.getByRole('button', { name: 'Query' }))
+      await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
+      expect(screen.getByText('OnUpdate')).toBeInTheDocument()
+      expect(screen.getByText('Mystery')).toBeInTheDocument()
+      expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
+    })
   })
 
-  it('shows success status dot for 2xx responses and error dot for 4xx/5xx', () => {
-    mockHook([makeRequest({ id: '1', status: 200 }), makeRequest({ id: '2', status: 500 })])
-    const { container } = render()
-    expect(container.querySelectorAll('.gt-status-dot--success')).toHaveLength(1)
-    expect(container.querySelectorAll('.gt-status-dot--error')).toHaveLength(1)
-  })
+  describe('Preserve log', () => {
+    it('Preserve log checkbox is unchecked by default', () => {
+      mockHook([])
+      render()
+      expect(screen.getByRole('checkbox')).not.toBeChecked()
+    })
 
-  // ---------------------------------------------------------------------------
-  // Clear button
-  // ---------------------------------------------------------------------------
+    it('clicking the Preserve log checkbox checks it', async () => {
+      mockHook([])
+      render()
+      await userEvent.click(screen.getByRole('checkbox'))
+      expect(screen.getByRole('checkbox')).toBeChecked()
+    })
 
-  it('Clear button calls clear() when clicked', async () => {
-    const clear = vi.fn()
-    mockHook([], clear)
-    render()
-    await userEvent.click(screen.getByRole('button', { name: 'Clear network log' }))
-    expect(clear).toHaveBeenCalledOnce()
-  })
+    it('calls useGraphQLRequests(true) by default (clear on navigation)', () => {
+      mockHook([])
+      render()
+      expect(mockUseGraphQLRequests).toHaveBeenCalledWith(true)
+    })
 
-  // ---------------------------------------------------------------------------
-  // Type filter
-  // ---------------------------------------------------------------------------
-
-  it('renders Query and Mutation filter buttons, both initially active', () => {
-    mockHook([])
-    render()
-    expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true')
-    expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute('aria-pressed', 'true')
-    expect(screen.queryByRole('button', { name: 'Subscription' })).not.toBeInTheDocument()
-    expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument()
+    it('calls useGraphQLRequests(false) after enabling Preserve log (keep log on navigation)', async () => {
+      mockHook([])
+      render()
+      await userEvent.click(screen.getByRole('checkbox'))
+      expect(mockUseGraphQLRequests).toHaveBeenLastCalledWith(false)
+    })
   })
 
-  it('deactivating Query hides query rows but keeps mutation rows', async () => {
-    mockHook([
-      makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
-      makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
-    ])
-    render()
-    await userEvent.click(screen.getByRole('button', { name: 'Query' }))
-    expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'false')
-    expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
-    expect(screen.getByText('CreateUser')).toBeInTheDocument()
-  })
+  describe('Column resize', () => {
+    it('renders 4 resize handles (one per resizable header cell)', () => {
+      mockHook([])
+      render()
+      expect(document.querySelectorAll('.gt-col-resize-handle')).toHaveLength(4)
+    })
 
-  it('deactivating Mutation hides mutation rows but keeps query rows', async () => {
-    mockHook([
-      makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
-      makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
-    ])
-    render()
-    await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
-    expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute(
-      'aria-pressed',
-      'false'
+    it('initial --gt-col-widths is 200px 100px 100px 100px 1fr', () => {
+      mockHook([])
+      render()
+      const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('200px 100px 100px 100px 1fr')
+    })
+
+    it.each([
+      {
+        handleIndex: 0,
+        label: 'Operation',
+        defaultWidth: 200,
+        delta: 50,
+        expected: '250px 100px 100px 100px 1fr',
+      },
+      {
+        handleIndex: 1,
+        label: 'Status',
+        defaultWidth: 100,
+        delta: 40,
+        expected: '200px 140px 100px 100px 1fr',
+      },
+      {
+        handleIndex: 2,
+        label: 'Size',
+        defaultWidth: 100,
+        delta: 30,
+        expected: '200px 100px 130px 100px 1fr',
+      },
+      {
+        handleIndex: 3,
+        label: 'Time',
+        defaultWidth: 100,
+        delta: 20,
+        expected: '200px 100px 100px 120px 1fr',
+      },
+    ])(
+      'dragging handle[$handleIndex] ($label) updates only that column',
+      ({ handleIndex, delta, expected }) => {
+        mockHook([])
+        render()
+        const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
+        const handles = document.querySelectorAll('.gt-col-resize-handle')
+
+        fireEvent.mouseDown(handles[handleIndex], { clientX: 100 })
+        fireEvent.mouseMove(document, { clientX: 100 + delta })
+
+        expect(panel.style.getPropertyValue('--gt-col-widths')).toBe(expected)
+      }
     )
-    expect(screen.queryByText('CreateUser')).not.toBeInTheDocument()
-    expect(screen.getByText('GetHero')).toBeInTheDocument()
-  })
-
-  it('deactivating both filters hides all query/mutation rows and shows empty state', async () => {
-    mockHook([
-      makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }),
-      makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }),
-    ])
-    render()
-    await userEvent.click(screen.getByRole('button', { name: 'Query' }))
-    await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
-    expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
-    expect(screen.queryByText('CreateUser')).not.toBeInTheDocument()
-    expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument()
-  })
-
-  it('re-clicking a deactivated filter reactivates it and shows the rows again', async () => {
-    mockHook([makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' })])
-    render()
-    const queryBtn = screen.getByRole('button', { name: 'Query' })
-    await userEvent.click(queryBtn)
-    expect(queryBtn).toHaveAttribute('aria-pressed', 'false')
-    expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
-    await userEvent.click(queryBtn)
-    expect(queryBtn).toHaveAttribute('aria-pressed', 'true')
-    expect(screen.getByText('GetHero')).toBeInTheDocument()
-  })
-
-  it('subscription and unknown requests are always shown regardless of filter state', async () => {
-    mockHook([
-      makeRequest({ id: '1', operationType: 'subscription', operationName: 'OnUpdate' }),
-      makeRequest({ id: '2', operationType: 'unknown', operationName: 'Mystery' }),
-      makeRequest({ id: '3', operationType: 'query', operationName: 'GetHero' }),
-    ])
-    render()
-    await userEvent.click(screen.getByRole('button', { name: 'Query' }))
-    await userEvent.click(screen.getByRole('button', { name: 'Mutation' }))
-    expect(screen.getByText('OnUpdate')).toBeInTheDocument()
-    expect(screen.getByText('Mystery')).toBeInTheDocument()
-    expect(screen.queryByText('GetHero')).not.toBeInTheDocument()
-  })
 
-  // ---------------------------------------------------------------------------
-  // Preserve log
-  // ---------------------------------------------------------------------------
-
-  it('Preserve log checkbox is unchecked by default', () => {
-    mockHook([])
-    render()
-    expect(screen.getByRole('checkbox')).not.toBeChecked()
-  })
-
-  it('clicking the Preserve log checkbox checks it', async () => {
-    mockHook([])
-    render()
-    await userEvent.click(screen.getByRole('checkbox'))
-    expect(screen.getByRole('checkbox')).toBeChecked()
-  })
-
-  it('calls useGraphQLRequests(true) by default (clear on navigation)', () => {
-    mockHook([])
-    render()
-    expect(mockUseGraphQLRequests).toHaveBeenCalledWith(true)
-  })
-
-  it('calls useGraphQLRequests(false) after enabling Preserve log (keep log on navigation)', async () => {
-    mockHook([])
-    render()
-    await userEvent.click(screen.getByRole('checkbox'))
-    expect(mockUseGraphQLRequests).toHaveBeenLastCalledWith(false)
-  })
-
-  // ---------------------------------------------------------------------------
-  // Column resize
-  // ---------------------------------------------------------------------------
-
-  it('renders 4 resize handles (one per resizable header cell)', () => {
-    mockHook([])
-    render()
-    expect(document.querySelectorAll('.gt-col-resize-handle')).toHaveLength(4)
-  })
-
-  it('initial --gt-col-widths is 200px 100px 100px 100px 1fr', () => {
-    mockHook([])
-    render()
-    const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('200px 100px 100px 100px 1fr')
-  })
-
-  it.each([
-    {
-      handleIndex: 0,
-      label: 'Operation',
-      defaultWidth: 200,
-      delta: 50,
-      expected: '250px 100px 100px 100px 1fr',
-    },
-    {
-      handleIndex: 1,
-      label: 'Status',
-      defaultWidth: 100,
-      delta: 40,
-      expected: '200px 140px 100px 100px 1fr',
-    },
-    {
-      handleIndex: 2,
-      label: 'Size',
-      defaultWidth: 100,
-      delta: 30,
-      expected: '200px 100px 130px 100px 1fr',
-    },
-    {
-      handleIndex: 3,
-      label: 'Time',
-      defaultWidth: 100,
-      delta: 20,
-      expected: '200px 100px 100px 120px 1fr',
-    },
-  ])(
-    'dragging handle[$handleIndex] ($label) updates only that column',
-    ({ handleIndex, delta, expected }) => {
+    it('column width is clamped to MIN_COL_WIDTH (40px) when dragged far left', () => {
       mockHook([])
       render()
       const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
       const handles = document.querySelectorAll('.gt-col-resize-handle')
 
-      fireEvent.mouseDown(handles[handleIndex], { clientX: 100 })
-      fireEvent.mouseMove(document, { clientX: 100 + delta })
+      fireEvent.mouseDown(handles[0], { clientX: 100 })
+      fireEvent.mouseMove(document, { clientX: -500 })
 
-      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe(expected)
-    }
-  )
-
-  it('column width is clamped to MIN_COL_WIDTH (40px) when dragged far left', () => {
-    mockHook([])
-    render()
-    const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
-    const handles = document.querySelectorAll('.gt-col-resize-handle')
-
-    fireEvent.mouseDown(handles[0], { clientX: 100 })
-    fireEvent.mouseMove(document, { clientX: -500 })
-
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('40px 100px 100px 100px 1fr')
-  })
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('40px 100px 100px 100px 1fr')
+    })
 
-  it('mouseup ends the drag; subsequent mousemoves do not change the width', () => {
-    mockHook([])
-    render()
-    const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
-    const handles = document.querySelectorAll('.gt-col-resize-handle')
+    it('mouseup ends the drag; subsequent mousemoves do not change the width', () => {
+      mockHook([])
+      render()
+      const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
+      const handles = document.querySelectorAll('.gt-col-resize-handle')
 
-    fireEvent.mouseDown(handles[0], { clientX: 100 })
-    fireEvent.mouseMove(document, { clientX: 150 })
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
+      fireEvent.mouseDown(handles[0], { clientX: 100 })
+      fireEvent.mouseMove(document, { clientX: 150 })
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
 
-    fireEvent.mouseUp(document)
-    fireEvent.mouseMove(document, { clientX: 300 })
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
-  })
+      fireEvent.mouseUp(document)
+      fireEvent.mouseMove(document, { clientX: 300 })
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
+    })
 
-  it('each drag measures from its own mousedown origin (not cumulative)', () => {
-    mockHook([])
-    render()
-    const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
-    const handles = document.querySelectorAll('.gt-col-resize-handle')
-
-    // First drag: 200 → 250
-    fireEvent.mouseDown(handles[0], { clientX: 100 })
-    fireEvent.mouseMove(document, { clientX: 150 })
-    fireEvent.mouseUp(document)
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
-
-    // Second drag starts from new baseline (250px), move +30 → 280
-    fireEvent.mouseDown(handles[0], { clientX: 200 })
-    fireEvent.mouseMove(document, { clientX: 230 })
-    expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('280px 100px 100px 100px 1fr')
-  })
+    it('each drag measures from its own mousedown origin (not cumulative)', () => {
+      mockHook([])
+      render()
+      const panel = document.querySelector('.gt-devtools-panel') as HTMLElement
+      const handles = document.querySelectorAll('.gt-col-resize-handle')
 
-  // ---------------------------------------------------------------------------
-  // Context menu
-  // ---------------------------------------------------------------------------
-
-  it('right-clicking a row opens the context menu', () => {
-    mockHook([makeRequest()])
-    render()
-    const row = document.querySelector('.gt-network-row') as HTMLElement
-    fireEvent.contextMenu(row, { clientX: 100, clientY: 200 })
-    expect(screen.getByRole('menu')).toBeInTheDocument()
-    expect(screen.getByText('Copy URL')).toBeInTheDocument()
-    expect(screen.getByText('Copy Query')).toBeInTheDocument()
+      // First drag: 200 → 250
+      fireEvent.mouseDown(handles[0], { clientX: 100 })
+      fireEvent.mouseMove(document, { clientX: 150 })
+      fireEvent.mouseUp(document)
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr')
+
+      // Second drag starts from new baseline (250px), move +30 → 280
+      fireEvent.mouseDown(handles[0], { clientX: 200 })
+      fireEvent.mouseMove(document, { clientX: 230 })
+      expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('280px 100px 100px 100px 1fr')
+    })
   })
 
-  it('context menu closes when clicking outside it', () => {
-    mockHook([makeRequest()])
-    render()
-    const row = document.querySelector('.gt-network-row') as HTMLElement
-    fireEvent.contextMenu(row, { clientX: 100, clientY: 200 })
-    expect(screen.getByRole('menu')).toBeInTheDocument()
-    fireEvent.mouseDown(document.body, { button: 0 })
-    expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+  describe('Context menu', () => {
+    it('right-clicking a row opens the context menu', () => {
+      mockHook([makeRequest()])
+      render()
+      const row = document.querySelector('.gt-network-row') as HTMLElement
+      fireEvent.contextMenu(row, { clientX: 100, clientY: 200 })
+      expect(screen.getByRole('menu')).toBeInTheDocument()
+      expect(screen.getByText('Copy URL')).toBeInTheDocument()
+      expect(screen.getByText('Copy Query')).toBeInTheDocument()
+    })
+
+    it('context menu closes when clicking outside it', () => {
+      mockHook([makeRequest()])
+      render()
+      const row = document.querySelector('.gt-network-row') as HTMLElement
+      fireEvent.contextMenu(row, { clientX: 100, clientY: 200 })
+      expect(screen.getByRole('menu')).toBeInTheDocument()
+      fireEvent.mouseDown(document.body, { button: 0 })
+      expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+    })
   })
 
-  // ---------------------------------------------------------------------------
-  // Request modal
-  // ---------------------------------------------------------------------------
-
-  it('left-clicking a row opens the request modal', () => {
-    mockHook([makeRequest()])
-    render()
-    const row = document.querySelector('.gt-network-row') as HTMLElement
-    fireEvent.click(row)
-    expect(screen.getByRole('dialog')).toBeInTheDocument()
-  })
+  describe('Request modal', () => {
+    it('left-clicking a row opens the request modal', () => {
+      mockHook([makeRequest()])
+      render()
+      const row = document.querySelector('.gt-network-row') as HTMLElement
+      fireEvent.click(row)
+      expect(screen.getByRole('dialog')).toBeInTheDocument()
+    })
 
-  it('modal shows the operation name of the clicked row', () => {
-    mockHook([makeRequest({ operationName: 'GetHero' })])
-    render()
-    const row = document.querySelector('.gt-network-row') as HTMLElement
-    fireEvent.click(row)
-    expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero')
-  })
+    it('modal shows the operation name of the clicked row', () => {
+      mockHook([makeRequest({ operationName: 'GetHero' })])
+      render()
+      const row = document.querySelector('.gt-network-row') as HTMLElement
+      fireEvent.click(row)
+      expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero')
+    })
 
-  it('pressing Escape closes the modal', () => {
-    mockHook([makeRequest()])
-    render()
-    const row = document.querySelector('.gt-network-row') as HTMLElement
-    fireEvent.click(row)
-    expect(screen.getByRole('dialog')).toBeInTheDocument()
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+    it('pressing Escape closes the modal', () => {
+      mockHook([makeRequest()])
+      render()
+      const row = document.querySelector('.gt-network-row') as HTMLElement
+      fireEvent.click(row)
+      expect(screen.getByRole('dialog')).toBeInTheDocument()
+      fireEvent.keyDown(document, { key: 'Escape' })
+      expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+    })
   })
 })
diff --git a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx
index 6855692..1fc22aa 100644
--- a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx
@@ -107,6 +107,13 @@ describe('ContextMenu', () => {
     expect(onClose).toHaveBeenCalledOnce()
   })
 
+  it('pressing a non-Escape key does not call onClose', () => {
+    const onClose = vi.fn()
+    renderMenu(makeRequest(), onClose)
+    fireEvent.keyDown(document, { key: 'Enter' })
+    expect(onClose).not.toHaveBeenCalled()
+  })
+
   it('left-clicking outside the menu calls onClose', () => {
     const onClose = vi.fn()
     renderMenu(makeRequest(), onClose)
diff --git a/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx
index 2a91cc9..c3109e6 100644
--- a/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx
@@ -2,7 +2,6 @@ import { cleanup, render, screen, fireEvent, act } from '@testing-library/react'
 // @vitest-environment jsdom
 import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
 import '@testing-library/jest-dom/vitest'
-
 import { CopyButton } from '../CopyButton'
 
 describe('CopyButton', () => {
@@ -82,4 +81,12 @@ describe('CopyButton', () => {
     expect(screen.getByTitle('Copy value')).toHaveClass('my-btn')
     expect(screen.getByTitle('Copy value')).not.toHaveClass('gt-headers-copy-btn')
   })
+
+  it('does not throw when clipboard write rejects', async () => {
+    vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('denied'))
+    render()
+    fireEvent.click(screen.getByTitle('Copy value'))
+    // Allow microtasks to settle — the .catch(() => {}) swallows the error
+    await Promise.resolve()
+  })
 })
diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx
index 96d8b75..a0c2511 100644
--- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx
@@ -89,6 +89,13 @@ describe('RequestModal', () => {
     expect(onClose).toHaveBeenCalledOnce()
   })
 
+  it('pressing a non-Escape key does not call onClose', () => {
+    const onClose = vi.fn()
+    renderModal(makeRequest(), onClose)
+    fireEvent.keyDown(document, { key: 'Enter' })
+    expect(onClose).not.toHaveBeenCalled()
+  })
+
   it('clicking the backdrop calls onClose', () => {
     const onClose = vi.fn()
     const { container } = renderModal(makeRequest(), onClose)
@@ -112,50 +119,44 @@ describe('RequestModal', () => {
     expect(onClose).not.toHaveBeenCalled()
   })
 
-  // ---------------------------------------------------------------------------
-  // Headers tab content
-  // ---------------------------------------------------------------------------
-
-  it('Headers tab renders Request Headers and Response Headers sections', () => {
-    renderModal()
-    expect(screen.getByText('Request Headers')).toBeInTheDocument()
-    expect(screen.getByText('Response Headers')).toBeInTheDocument()
-  })
-
-  it('Headers tab content is not visible when a different tab is active', () => {
-    renderModal()
-    fireEvent.click(screen.getByRole('tab', { name: 'Request' }))
-    expect(screen.queryByText('Request Headers')).not.toBeInTheDocument()
-    expect(screen.queryByText('Response Headers')).not.toBeInTheDocument()
-  })
-
-  // ---------------------------------------------------------------------------
-  // Request tab content
-  // ---------------------------------------------------------------------------
-
-  it('Request tab renders RequestTab component when active', () => {
-    renderModal()
-    fireEvent.click(screen.getByRole('tab', { name: 'Request' }))
-    expect(screen.getByTestId('request-tab-mock')).toBeInTheDocument()
-  })
-
-  it('RequestTab is not in DOM when a different tab is active', () => {
-    renderModal()
-    expect(screen.queryByTestId('request-tab-mock')).not.toBeInTheDocument()
-  })
-
-  // ---------------------------------------------------------------------------
-  // Response tab content
-  // ---------------------------------------------------------------------------
-
-  it('Response tab renders ResponseTab component when active', () => {
-    renderModal()
-    fireEvent.click(screen.getByRole('tab', { name: 'Response' }))
-    expect(screen.getByTestId('response-tab-mock')).toBeInTheDocument()
-  })
-
-  it('ResponseTab is not in DOM when a different tab is active', () => {
-    renderModal()
-    expect(screen.queryByTestId('response-tab-mock')).not.toBeInTheDocument()
+  describe('Headers tab content', () => {
+    it('Headers tab renders Request Headers and Response Headers sections', () => {
+      renderModal()
+      expect(screen.getByText('Request Headers')).toBeInTheDocument()
+      expect(screen.getByText('Response Headers')).toBeInTheDocument()
+    })
+
+    it('Headers tab content is not visible when a different tab is active', () => {
+      renderModal()
+      fireEvent.click(screen.getByRole('tab', { name: 'Request' }))
+      expect(screen.queryByText('Request Headers')).not.toBeInTheDocument()
+      expect(screen.queryByText('Response Headers')).not.toBeInTheDocument()
+    })
+  })
+
+  describe('Request tab content', () => {
+    it('Request tab renders RequestTab component when active', () => {
+      renderModal()
+      fireEvent.click(screen.getByRole('tab', { name: 'Request' }))
+      expect(screen.getByTestId('request-tab-mock')).toBeInTheDocument()
+    })
+
+    it('RequestTab is not in DOM when a different tab is active', () => {
+      renderModal()
+      expect(screen.queryByTestId('request-tab-mock')).not.toBeInTheDocument()
+    })
+  })
+
+  describe('Response tab content', () => {
+    it('Response tab renders ResponseTab component when active', () => {
+      renderModal()
+      fireEvent.click(screen.getByRole('tab', { name: 'Response' }))
+      expect(screen.getByTestId('response-tab-mock')).toBeInTheDocument()
+    })
+
+    it('ResponseTab is not in DOM when a different tab is active', () => {
+      renderModal()
+      expect(screen.queryByTestId('response-tab-mock')).not.toBeInTheDocument()
+    })
   })
 })
diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
index 64f130f..c174cf0 100644
--- a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx
@@ -5,8 +5,8 @@ import '@testing-library/jest-dom/vitest'
 
 vi.mock('../RequestTab.css', () => ({}))
 vi.mock('@microlink/react-json-view', () => ({
-  default: ({ src }: { src: object }) => (
-    
+ default: ({ src, theme }: { src: object; theme?: string }) => ( +
), })) @@ -45,173 +45,192 @@ describe('RequestTab', () => { vi.unstubAllGlobals() }) - // --------------------------------------------------------------------------- - // Query section - // --------------------------------------------------------------------------- + describe('Query section', () => { + it('renders a "Query" section heading', () => { + render() + expect(screen.getByText('Query')).toBeInTheDocument() + }) - it('renders a "Query" section heading', () => { - render() - expect(screen.getByText('Query')).toBeInTheDocument() - }) + it('displays the query text in a code block', () => { + render() + const code = document.querySelector('pre code') + expect(code).not.toBeNull() + expect(code!.textContent).toContain('GetHero') + }) - it('displays the query text in a code block', () => { - render() - const code = document.querySelector('pre code') - expect(code).not.toBeNull() - expect(code!.textContent).toContain('GetHero') - }) + it('formats a compact query using print()', () => { + render() + const code = document.querySelector('pre code') + // print() adds whitespace/newlines around braces + expect(code!.textContent).toMatch(/GetHero\s*\{/) + expect(code!.textContent).toContain('\n') + }) - it('formats a compact query using print()', () => { - render() - const code = document.querySelector('pre code') - // print() adds whitespace/newlines around braces - expect(code!.textContent).toMatch(/GetHero\s*\{/) - expect(code!.textContent).toContain('\n') - }) + it('falls back to raw query string when the query is invalid GraphQL', () => { + const raw = '!@#invalid graphql' + render() + const code = document.querySelector('pre code') + expect(code!.textContent).toBe(raw) + }) - it('falls back to raw query string when the query is invalid GraphQL', () => { - const raw = '!@#invalid graphql' - render() - const code = document.querySelector('pre code') - expect(code!.textContent).toBe(raw) - }) + it('copy button is present in the Query section', () => { + render() + expect(screen.getByTitle('Copy query')).toBeInTheDocument() + }) - it('copy button is present in the Query section', () => { - render() - expect(screen.getByTitle('Copy query')).toBeInTheDocument() - }) + it('clicking copy query button writes the formatted query to clipboard', () => { + render() + fireEvent.click(screen.getByTitle('Copy query')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('GetHero')) + }) - it('clicking copy query button writes the formatted query to clipboard', () => { - render() - fireEvent.click(screen.getByTitle('Copy query')) - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('GetHero')) - }) + it('renders a "Raw" toggle button in the Query section', () => { + render() + const btn = screen.getByRole('button', { name: 'Raw' }) + expect(btn).toBeInTheDocument() + expect(btn).toHaveAttribute('title', 'Display original, unformatted value') + }) - it('renders a "Raw" toggle button in the Query section', () => { - render() - const btn = screen.getByRole('button', { name: 'Raw' }) - expect(btn).toBeInTheDocument() - expect(btn).toHaveAttribute('title', 'Display original, unformatted value') - }) + it('Raw toggle is inactive by default', () => { + render() + expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active') + }) - it('Raw toggle is inactive by default', () => { - render() - expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active') - }) + it('clicking Raw toggle shows the unformatted query without syntax highlighting', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + const code = document.querySelector('pre code') + expect(code!.textContent).toBe(compactQuery) + expect(code!.innerHTML).toBe(compactQuery) + }) - it('clicking Raw toggle shows the unformatted query without syntax highlighting', () => { - const compactQuery = 'query GetHero{hero{name}}' - render() - fireEvent.click(screen.getByRole('button', { name: 'Raw' })) - const code = document.querySelector('pre code') - expect(code!.textContent).toBe(compactQuery) - expect(code!.innerHTML).toBe(compactQuery) - }) + it('clicking Raw toggle marks the button as active', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active') + }) - it('clicking Raw toggle marks the button as active', () => { - render() - fireEvent.click(screen.getByRole('button', { name: 'Raw' })) - expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active') - }) + it('clicking Raw toggle again reverts to formatted query', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + const code = document.querySelector('pre code') + expect(code!.textContent).not.toBe(compactQuery) + expect(code!.textContent).toContain('\n') + }) - it('clicking Raw toggle again reverts to formatted query', () => { - const compactQuery = 'query GetHero{hero{name}}' - render() - fireEvent.click(screen.getByRole('button', { name: 'Raw' })) - fireEvent.click(screen.getByRole('button', { name: 'Raw' })) - const code = document.querySelector('pre code') - expect(code!.textContent).not.toBe(compactQuery) - expect(code!.textContent).toContain('\n') + it('copy button copies the raw query when Raw toggle is active', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + fireEvent.click(screen.getByTitle('Copy query')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(compactQuery) + }) }) - it('copy button copies the raw query when Raw toggle is active', () => { - const compactQuery = 'query GetHero{hero{name}}' - render() - fireEvent.click(screen.getByRole('button', { name: 'Raw' })) - fireEvent.click(screen.getByTitle('Copy query')) - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(compactQuery) - }) + describe('Variables section', () => { + it('Variables section is absent when variables is undefined', () => { + render() + expect(screen.queryByText('Variables')).not.toBeInTheDocument() + }) - // --------------------------------------------------------------------------- - // Variables section - // --------------------------------------------------------------------------- + it('renders "Variables" heading when variables is present', () => { + render() + expect(screen.getByText('Variables')).toBeInTheDocument() + }) - it('Variables section is absent when variables is undefined', () => { - render() - expect(screen.queryByText('Variables')).not.toBeInTheDocument() - }) + it('renders ReactJsonView with parsed variables when variables is valid JSON object', () => { + render() + const jsonView = screen.getByTestId('json-view') + expect(jsonView).toBeInTheDocument() + expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ id: '1' }) + }) - it('renders "Variables" heading when variables is present', () => { - render() - expect(screen.getByText('Variables')).toBeInTheDocument() - }) + it('uses monokai theme in dark mode', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true })) + ) + render() + expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') + }) - it('renders ReactJsonView with parsed variables when variables is valid JSON object', () => { - render() - const jsonView = screen.getByTestId('json-view') - expect(jsonView).toBeInTheDocument() - expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ id: '1' }) - }) + it('falls back to
 display when variables is not valid JSON', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      // The fallback pre block contains the raw string
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+      expect(fallback).toBeDefined()
+    })
 
-  it('falls back to 
 display when variables is not valid JSON', () => {
-    render()
-    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
-    // The fallback pre block contains the raw string
-    const pres = document.querySelectorAll('pre')
-    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
-    expect(fallback).toBeDefined()
-  })
+    it('falls back to 
 display when variables is a JSON array', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === '[1,2,3]')
+      expect(fallback).toBeDefined()
+    })
 
-  it('copy button is present in the Variables section', () => {
-    render()
-    expect(screen.getByTitle('Copy variables')).toBeInTheDocument()
-  })
+    it('copy button is present in the Variables section', () => {
+      render()
+      expect(screen.getByTitle('Copy variables')).toBeInTheDocument()
+    })
 
-  it('clicking copy variables button writes the variables string to clipboard', () => {
-    const variables = '{\n  "id": "1"\n}'
-    render()
-    fireEvent.click(screen.getByTitle('Copy variables'))
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(variables)
+    it('clicking copy variables button writes the variables string to clipboard', () => {
+      const variables = '{\n  "id": "1"\n}'
+      render()
+      fireEvent.click(screen.getByTitle('Copy variables'))
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith(variables)
+    })
   })
 
-  // ---------------------------------------------------------------------------
-  // Extensions section
-  // ---------------------------------------------------------------------------
+  describe('Extensions section', () => {
+    it('Extensions section is absent when extensions is undefined', () => {
+      render()
+      expect(screen.queryByText('Extensions')).not.toBeInTheDocument()
+    })
 
-  it('Extensions section is absent when extensions is undefined', () => {
-    render()
-    expect(screen.queryByText('Extensions')).not.toBeInTheDocument()
-  })
+    it('renders "Extensions" heading when extensions is present', () => {
+      render()
+      expect(screen.getByText('Extensions')).toBeInTheDocument()
+    })
 
-  it('renders "Extensions" heading when extensions is present', () => {
-    render()
-    expect(screen.getByText('Extensions')).toBeInTheDocument()
-  })
+    it('renders ReactJsonView with parsed extensions when extensions is valid JSON object', () => {
+      render()
+      const jsonView = screen.getByTestId('json-view')
+      expect(jsonView).toBeInTheDocument()
+      expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ tracing: true })
+    })
 
-  it('renders ReactJsonView with parsed extensions when extensions is valid JSON object', () => {
-    render()
-    const jsonView = screen.getByTestId('json-view')
-    expect(jsonView).toBeInTheDocument()
-    expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ tracing: true })
-  })
+    it('falls back to 
 display when extensions is not valid JSON', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+      expect(fallback).toBeDefined()
+    })
 
-  it('falls back to 
 display when extensions is not valid JSON', () => {
-    render()
-    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
-    const pres = document.querySelectorAll('pre')
-    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
-    expect(fallback).toBeDefined()
-  })
+    it('falls back to 
 display when extensions is a JSON array', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === '[1,2,3]')
+      expect(fallback).toBeDefined()
+    })
 
-  it('copy button is present in the Extensions section', () => {
-    render()
-    expect(screen.getByTitle('Copy extensions')).toBeInTheDocument()
-  })
+    it('copy button is present in the Extensions section', () => {
+      render()
+      expect(screen.getByTitle('Copy extensions')).toBeInTheDocument()
+    })
 
-  it('clicking copy extensions button writes the extensions string to clipboard', () => {
-    const extensions = '{\n  "tracing": true\n}'
-    render()
-    fireEvent.click(screen.getByTitle('Copy extensions'))
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(extensions)
+    it('clicking copy extensions button writes the extensions string to clipboard', () => {
+      const extensions = '{\n  "tracing": true\n}'
+      render()
+      fireEvent.click(screen.getByTitle('Copy extensions'))
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith(extensions)
+    })
   })
 })
diff --git a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
index 3b49256..e98ab39 100644
--- a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
+++ b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
@@ -5,8 +5,8 @@ import '@testing-library/jest-dom/vitest'
 
 vi.mock('../ResponseTab.css', () => ({}))
 vi.mock('@microlink/react-json-view', () => ({
-  default: ({ src }: { src: object }) => (
-    
+ default: ({ src, theme }: { src: object; theme?: string }) => ( +
), })) @@ -45,116 +45,127 @@ describe('ResponseTab', () => { vi.unstubAllGlobals() }) - // --------------------------------------------------------------------------- - // Empty state - // --------------------------------------------------------------------------- + describe('Empty state', () => { + it('shows empty state when response is undefined', () => { + render() + expect(screen.getByText('No response body')).toBeInTheDocument() + }) - it('shows empty state when response is undefined', () => { - render() - expect(screen.getByText('No response body')).toBeInTheDocument() + it('does not render the Body heading when response is undefined', () => { + render() + expect(screen.queryByText('Body')).not.toBeInTheDocument() + }) }) - it('does not render the Body heading when response is undefined', () => { - render() - expect(screen.queryByText('Body')).not.toBeInTheDocument() - }) + describe('Body section', () => { + it('renders "Body" heading when response is present', () => { + render( + + ) + expect(screen.getByText('Body')).toBeInTheDocument() + }) - // --------------------------------------------------------------------------- - // Body section - // --------------------------------------------------------------------------- + it('renders ReactJsonView with parsed response when response is valid JSON object', () => { + const response = '{"data":{"hero":{"name":"Luke"}}}' + render() + const jsonView = screen.getByTestId('json-view') + expect(jsonView).toBeInTheDocument() + expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ + data: { hero: { name: 'Luke' } }, + }) + }) - it('renders "Body" heading when response is present', () => { - render() - expect(screen.getByText('Body')).toBeInTheDocument() - }) + it('uses monokai theme in dark mode', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true })) + ) + render() + expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') + }) - it('renders ReactJsonView with parsed response when response is valid JSON object', () => { - const response = '{"data":{"hero":{"name":"Luke"}}}' - render() - const jsonView = screen.getByTestId('json-view') - expect(jsonView).toBeInTheDocument() - expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ - data: { hero: { name: 'Luke' } }, + it('falls back to
 display when response is not valid JSON', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+      expect(fallback).toBeDefined()
     })
-  })
 
-  it('falls back to 
 display when response is not valid JSON', () => {
-    render()
-    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
-    const pres = document.querySelectorAll('pre')
-    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
-    expect(fallback).toBeDefined()
+    it('falls back to 
 display when response is a JSON array', () => {
+      render()
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pres = document.querySelectorAll('pre')
+      const fallback = Array.from(pres).find((p) => p.textContent === '[1,2,3]')
+      expect(fallback).toBeDefined()
+    })
   })
 
-  // ---------------------------------------------------------------------------
-  // Copy button
-  // ---------------------------------------------------------------------------
-
-  it('copy button is present when response is present', () => {
-    render()
-    expect(screen.getByTitle('Copy response')).toBeInTheDocument()
-  })
+  describe('Copy button', () => {
+    it('copy button is present when response is present', () => {
+      render()
+      expect(screen.getByTitle('Copy response')).toBeInTheDocument()
+    })
 
-  it('clicking copy response copies prettified JSON when Raw is inactive and response is valid JSON', () => {
-    const response = '{"data":{}}'
-    render()
-    fireEvent.click(screen.getByTitle('Copy response'))
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
-      JSON.stringify({ data: {} }, null, 2)
-    )
-  })
+    it('clicking copy response copies prettified JSON when Raw is inactive and response is valid JSON', () => {
+      const response = '{"data":{}}'
+      render()
+      fireEvent.click(screen.getByTitle('Copy response'))
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+        JSON.stringify({ data: {} }, null, 2)
+      )
+    })
 
-  it('clicking copy response copies raw string when Raw is active', () => {
-    const response = '{"data":{}}'
-    render()
-    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
-    fireEvent.click(screen.getByTitle('Copy response'))
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
-  })
+    it('clicking copy response copies raw string when Raw is active', () => {
+      const response = '{"data":{}}'
+      render()
+      fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+      fireEvent.click(screen.getByTitle('Copy response'))
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
+    })
 
-  it('clicking copy response copies raw string when response is not valid JSON', () => {
-    const response = 'not-json'
-    render()
-    fireEvent.click(screen.getByTitle('Copy response'))
-    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
+    it('clicking copy response copies raw string when response is not valid JSON', () => {
+      const response = 'not-json'
+      render()
+      fireEvent.click(screen.getByTitle('Copy response'))
+      expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
+    })
   })
 
-  // ---------------------------------------------------------------------------
-  // Raw toggle
-  // ---------------------------------------------------------------------------
-
-  it('Raw toggle button is present when response is present', () => {
-    render()
-    const btn = screen.getByRole('button', { name: 'Raw' })
-    expect(btn).toBeInTheDocument()
-    expect(btn).toHaveAttribute('title', 'Display original, unformatted value')
-  })
+  describe('Raw toggle', () => {
+    it('Raw toggle button is present when response is present', () => {
+      render()
+      const btn = screen.getByRole('button', { name: 'Raw' })
+      expect(btn).toBeInTheDocument()
+      expect(btn).toHaveAttribute('title', 'Display original, unformatted value')
+    })
 
-  it('Raw toggle is inactive by default', () => {
-    render()
-    expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active')
-  })
+    it('Raw toggle is inactive by default', () => {
+      render()
+      expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active')
+    })
 
-  it('clicking Raw toggle shows raw response in 
', () => {
-    const response = '{"data":{}}'
-    render()
-    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
-    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
-    const pre = document.querySelector('pre')
-    expect(pre?.textContent).toBe(response)
-  })
+    it('clicking Raw toggle shows raw response in 
', () => {
+      const response = '{"data":{}}'
+      render()
+      fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+      expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+      const pre = document.querySelector('pre')
+      expect(pre?.textContent).toBe(response)
+    })
 
-  it('clicking Raw toggle marks the button as active', () => {
-    render()
-    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
-    expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active')
-  })
+    it('clicking Raw toggle marks the button as active', () => {
+      render()
+      fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+      expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active')
+    })
 
-  it('clicking Raw toggle again reverts to ReactJsonView', () => {
-    const response = '{"data":{}}'
-    render()
-    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
-    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
-    expect(screen.getByTestId('json-view')).toBeInTheDocument()
+    it('clicking Raw toggle again reverts to ReactJsonView', () => {
+      const response = '{"data":{}}'
+      render()
+      fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+      fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+      expect(screen.getByTestId('json-view')).toBeInTheDocument()
+    })
   })
 })

From ae5a8f94071c93bdd46fbb522bf7cf8edf54618b Mon Sep 17 00:00:00 2001
From: Jason Simeone 
Date: Wed, 4 Mar 2026 00:30:31 -0500
Subject: [PATCH 16/30] Remove unnecessary dep

---
 package.json   |  1 -
 pnpm-lock.yaml | 14 --------------
 2 files changed, 15 deletions(-)

diff --git a/package.json b/package.json
index 8f47387..c07f048 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,6 @@
     "@types/chrome": "^0.1.37",
     "@types/react": "^19.2.14",
     "@types/react-dom": "^19.2.3",
-    "@types/react-window": "^2.0.0",
     "@vitest/coverage-v8": "^4.0.18",
     "@wxt-dev/auto-icons": "^1.1.1",
     "@wxt-dev/module-react": "^1.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d69e1a8..b2e4e1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,9 +72,6 @@ importers:
       '@types/react-dom':
         specifier: ^19.2.3
         version: 19.2.3(@types/react@19.2.14)
-      '@types/react-window':
-        specifier: ^2.0.0
-        version: 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       '@vitest/coverage-v8':
         specifier: ^4.0.18
         version: 4.0.18(vitest@4.0.18(@types/node@25.3.5)(jiti@2.6.1)(jsdom@28.1.0))
@@ -1589,10 +1586,6 @@ packages:
     peerDependencies:
       '@types/react': ^19.2.0
 
-  '@types/react-window@2.0.0':
-    resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==}
-    deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed.
-
   '@types/react@19.2.14':
     resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
 
@@ -4838,13 +4831,6 @@ snapshots:
     dependencies:
       '@types/react': 19.2.14
 
-  '@types/react-window@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
-    dependencies:
-      react-window: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
-    transitivePeerDependencies:
-      - react
-      - react-dom
-
   '@types/react@19.2.14':
     dependencies:
       csstype: 3.2.3

From a5e4d576b9168c574eb856ddd3453f8104bc640b Mon Sep 17 00:00:00 2001
From: Jason Simeone 
Date: Wed, 4 Mar 2026 00:44:17 -0500
Subject: [PATCH 17/30] Fixed various issues from a code review

---
 entrypoints/devtools-panel/ContextMenu.tsx    |  2 +-
 entrypoints/devtools-panel/HeadersTable.tsx   |  4 +-
 entrypoints/devtools-panel/RequestTab.tsx     | 14 +---
 entrypoints/devtools-panel/ResponseTab.tsx    | 14 +---
 .../__tests__/RequestTab.test.tsx             |  4 +-
 .../__tests__/ResponseTab.test.tsx            |  4 +-
 .../__tests__/useDarkMode.test.ts             | 79 +++++++++++++++++++
 entrypoints/devtools-panel/har.ts             | 10 +++
 entrypoints/devtools-panel/useDarkMode.ts     | 18 +++++
 9 files changed, 120 insertions(+), 29 deletions(-)
 create mode 100644 entrypoints/devtools-panel/__tests__/useDarkMode.test.ts
 create mode 100644 entrypoints/devtools-panel/useDarkMode.ts

diff --git a/entrypoints/devtools-panel/ContextMenu.tsx b/entrypoints/devtools-panel/ContextMenu.tsx
index da186cd..6caeaaa 100644
--- a/entrypoints/devtools-panel/ContextMenu.tsx
+++ b/entrypoints/devtools-panel/ContextMenu.tsx
@@ -22,8 +22,8 @@ function prettyJson(value: string): string {
 function copyAndClose(text: string, onClose: () => void) {
   navigator.clipboard
     .writeText(text)
-    .finally(onClose)
     .catch(() => {})
+    .finally(onClose)
 }
 
 export function ContextMenu({ x, y, request, onClose }: Props) {
diff --git a/entrypoints/devtools-panel/HeadersTable.tsx b/entrypoints/devtools-panel/HeadersTable.tsx
index 9a8931b..ed51922 100644
--- a/entrypoints/devtools-panel/HeadersTable.tsx
+++ b/entrypoints/devtools-panel/HeadersTable.tsx
@@ -28,8 +28,8 @@ export function HeadersTable({ title, headers }: Props) {
             
           
           
- {visible.map(({ name, value }) => ( - + {visible.map(({ name, value }, index) => ( +
No GraphQL requests recorded.
diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index a3c09b6..38620e2 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -1,4 +1,5 @@ import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // @vitest-environment jsdom import { describe, it, expect, vi, afterEach } from 'vitest' import '@testing-library/jest-dom/vitest' @@ -26,13 +27,17 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { } } +function mockHook(requests: GraphQLRequest[], clear = vi.fn()) { + mockUseGraphQLRequests.mockReturnValue({ requests, clear }) +} + describe('DevTools Panel App', () => { afterEach(() => { cleanup() }) it('renders all 5 column headers', () => { - mockUseGraphQLRequests.mockReturnValue([]) + mockHook([]) render() expect(screen.getByText('Operation')).toBeInTheDocument() expect(screen.getByText('Status')).toBeInTheDocument() @@ -42,13 +47,13 @@ describe('DevTools Panel App', () => { }) it('shows empty state when hook returns []', () => { - mockUseGraphQLRequests.mockReturnValue([]) + mockHook([]) render() expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument() }) it('renders a row per request with operation name, status, and URL', () => { - mockUseGraphQLRequests.mockReturnValue([ + mockHook([ makeRequest({ id: '1', operationName: 'GetHero', @@ -71,62 +76,132 @@ describe('DevTools Panel App', () => { }) it('filesize formats size correctly: 512 → "512 B"', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ size: 512 })]) + mockHook([makeRequest({ size: 512 })]) render() expect(screen.getByText('512 B')).toBeInTheDocument() }) it('prettyMs formats time correctly: 123 → "123ms"', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ time: 123 })]) + mockHook([makeRequest({ time: 123 })]) render() expect(screen.getByText('123ms')).toBeInTheDocument() }) it('shows Q badge for query operations', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'query' })]) + mockHook([makeRequest({ operationType: 'query' })]) render() const badge = screen.getByText('Q') expect(badge).toHaveClass('gt-op-badge--query') }) it('shows M badge for mutation operations', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'mutation' })]) + mockHook([makeRequest({ operationType: 'mutation' })]) render() const badge = screen.getByText('M') expect(badge).toHaveClass('gt-op-badge--mutation') }) it('shows S badge for subscription operations', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'subscription' })]) + mockHook([makeRequest({ operationType: 'subscription' })]) render() const badge = screen.getByText('S') expect(badge).toHaveClass('gt-op-badge--subscription') }) it('shows Q badge for unknown operations', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ operationType: 'unknown' })]) + mockHook([makeRequest({ operationType: 'unknown' })]) render() const badge = screen.getByText('Q') expect(badge).toHaveClass('gt-op-badge--unknown') }) it('shows success dot for 2xx status', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 200 })]) + mockHook([makeRequest({ status: 200 })]) const { container } = render() expect(container.querySelector('.gt-status-dot--success')).toBeInTheDocument() expect(container.querySelector('.gt-status-dot--error')).not.toBeInTheDocument() }) it('shows error dot for 4xx status', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 400 })]) + mockHook([makeRequest({ status: 400 })]) const { container } = render() expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() expect(container.querySelector('.gt-status-dot--success')).not.toBeInTheDocument() }) it('shows error dot for 5xx status', () => { - mockUseGraphQLRequests.mockReturnValue([makeRequest({ status: 500 })]) + mockHook([makeRequest({ status: 500 })]) const { container } = render() expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() }) + + it('Clear button calls clear() when clicked', async () => { + const clear = vi.fn() + mockHook([], clear) + render() + await userEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect(clear).toHaveBeenCalledOnce() + }) + + it('renders three filter buttons (Query, Mutation, Subscription) initially active', () => { + mockHook([]) + render() + expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Subscription' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument() + }) + + it('clicking Query button deactivates it; Query rows hidden, Mutation rows still shown', async () => { + mockHook([ + makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }), + makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }), + ]) + render() + await userEvent.click(screen.getByRole('button', { name: 'Query' })) + expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'false') + expect(screen.queryByText('GetHero')).not.toBeInTheDocument() + expect(screen.getByText('CreateUser')).toBeInTheDocument() + }) + + it('clicking Mutation button deactivates it; Mutation rows hidden', async () => { + mockHook([makeRequest({ id: '1', operationType: 'mutation', operationName: 'CreateUser' })]) + render() + await userEvent.click(screen.getByRole('button', { name: 'Mutation' })) + expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute( + 'aria-pressed', + 'false' + ) + expect(screen.queryByText('CreateUser')).not.toBeInTheDocument() + }) + + it('re-clicking a deactivated filter button reactivates it', async () => { + mockHook([makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' })]) + render() + const queryBtn = screen.getByRole('button', { name: 'Query' }) + await userEvent.click(queryBtn) + expect(queryBtn).toHaveAttribute('aria-pressed', 'false') + expect(screen.queryByText('GetHero')).not.toBeInTheDocument() + await userEvent.click(queryBtn) + expect(queryBtn).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByText('GetHero')).toBeInTheDocument() + }) + + it('auto-clear checkbox is unchecked by default', () => { + mockHook([]) + render() + const checkbox = screen.getByRole('checkbox') + expect(checkbox).not.toBeChecked() + }) + + it('clicking the auto-clear checkbox checks it', async () => { + mockHook([]) + render() + const checkbox = screen.getByRole('checkbox') + await userEvent.click(checkbox) + expect(checkbox).toBeChecked() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index d00c375..1a49df5 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -5,30 +5,58 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { HAREntry } from '../har' import { useGraphQLRequests } from '../useGraphQLRequests' -type Listener = (entry: HAREntry) => void +type RequestListener = (entry: HAREntry) => void +type NavigatedListener = () => void function makeChromeMock() { - let capturedListener: Listener | null = null - const addListener = vi.fn((fn: Listener) => { - capturedListener = fn + let capturedRequestListener: RequestListener | null = null + const onRequestFinishedAddListener = vi.fn((fn: RequestListener) => { + capturedRequestListener = fn }) - const removeListener = vi.fn((fn: Listener) => { - if (capturedListener === fn) capturedListener = null + const onRequestFinishedRemoveListener = vi.fn((fn: RequestListener) => { + if (capturedRequestListener === fn) capturedRequestListener = null + }) + + let capturedNavigatedListeners: NavigatedListener[] = [] + const onNavigatedAddListener = vi.fn((fn: NavigatedListener) => { + capturedNavigatedListeners.push(fn) + }) + const onNavigatedRemoveListener = vi.fn((fn: NavigatedListener) => { + capturedNavigatedListeners = capturedNavigatedListeners.filter((l) => l !== fn) }) const chrome = { devtools: { network: { - onRequestFinished: { addListener, removeListener }, + onRequestFinished: { + addListener: onRequestFinishedAddListener, + removeListener: onRequestFinishedRemoveListener, + }, + onNavigated: { + addListener: onNavigatedAddListener, + removeListener: onNavigatedRemoveListener, + }, }, }, } function fire(entry: HAREntry) { - capturedListener?.(entry) + capturedRequestListener?.(entry) + } + + function fireNavigated() { + capturedNavigatedListeners.forEach((l) => l()) } - return { chrome, addListener, removeListener, fire } + return { + chrome, + addListener: onRequestFinishedAddListener, + removeListener: onRequestFinishedRemoveListener, + onNavigatedAddListener, + onNavigatedRemoveListener, + fire, + fireNavigated, + } } function makeGraphQLEntry(overrides: Partial = {}): HAREntry { @@ -70,29 +98,29 @@ describe('useGraphQLRequests', () => { }) it('returns empty array initially', () => { - const { result } = renderHook(() => useGraphQLRequests()) - expect(result.current).toEqual([]) + const { result } = renderHook(() => useGraphQLRequests(false)) + expect(result.current.requests).toEqual([]) }) it('registers listener on mount', () => { - renderHook(() => useGraphQLRequests()) + renderHook(() => useGraphQLRequests(false)) expect(mock.addListener).toHaveBeenCalledOnce() }) it('removes same listener on unmount', () => { - const { unmount } = renderHook(() => useGraphQLRequests()) + const { unmount } = renderHook(() => useGraphQLRequests(false)) const registeredFn = mock.addListener.mock.calls[0][0] unmount() expect(mock.removeListener).toHaveBeenCalledWith(registeredFn) }) it('adds a GraphQLRequest entry when a matching HAR entry fires', () => { - const { result } = renderHook(() => useGraphQLRequests()) + const { result } = renderHook(() => useGraphQLRequests(false)) act(() => { mock.fire(makeGraphQLEntry()) }) - expect(result.current).toHaveLength(1) - expect(result.current[0]).toMatchObject({ + expect(result.current.requests).toHaveLength(1) + expect(result.current.requests[0]).toMatchObject({ id: '1', operationName: 'GetHero', operationType: 'query', @@ -104,15 +132,15 @@ describe('useGraphQLRequests', () => { }) it('ignores non-GraphQL entries', () => { - const { result } = renderHook(() => useGraphQLRequests()) + const { result } = renderHook(() => useGraphQLRequests(false)) act(() => { mock.fire(makeNonGraphQLEntry()) }) - expect(result.current).toHaveLength(0) + expect(result.current.requests).toHaveLength(0) }) it('multiple entries accumulate in order', () => { - const { result } = renderHook(() => useGraphQLRequests()) + const { result } = renderHook(() => useGraphQLRequests(false)) act(() => { mock.fire(makeGraphQLEntry()) mock.fire( @@ -123,21 +151,78 @@ describe('useGraphQLRequests', () => { }) ) }) - expect(result.current).toHaveLength(2) - expect(result.current[0]).toMatchObject({ operationName: 'GetHero', operationType: 'query' }) - expect(result.current[1]).toMatchObject({ + expect(result.current.requests).toHaveLength(2) + expect(result.current.requests[0]).toMatchObject({ + operationName: 'GetHero', + operationType: 'query', + }) + expect(result.current.requests[1]).toMatchObject({ operationName: 'CreateUser', operationType: 'mutation', }) }) it('id increments monotonically across multiple entries', () => { - const { result } = renderHook(() => useGraphQLRequests()) + const { result } = renderHook(() => useGraphQLRequests(false)) act(() => { mock.fire(makeGraphQLEntry()) mock.fire(makeGraphQLEntry()) mock.fire(makeGraphQLEntry()) }) - expect(result.current.map((r) => r.id)).toEqual(['1', '2', '3']) + expect(result.current.requests.map((r) => r.id)).toEqual(['1', '2', '3']) + }) + + it('clear() empties the request list', () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + act(() => { + mock.fire(makeGraphQLEntry()) + mock.fire(makeGraphQLEntry()) + }) + expect(result.current.requests).toHaveLength(2) + act(() => { + result.current.clear() + }) + expect(result.current.requests).toHaveLength(0) + }) + + it('navigation event clears requests when autoClear is true', () => { + const { result } = renderHook(() => useGraphQLRequests(true)) + act(() => { + mock.fire(makeGraphQLEntry()) + }) + expect(result.current.requests).toHaveLength(1) + act(() => { + mock.fireNavigated() + }) + expect(result.current.requests).toHaveLength(0) + }) + + it('navigation event does NOT clear when autoClear is false', () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + act(() => { + mock.fire(makeGraphQLEntry()) + }) + expect(result.current.requests).toHaveLength(1) + act(() => { + mock.fireNavigated() + }) + expect(result.current.requests).toHaveLength(1) + }) + + it('onNavigated listener is removed on unmount when autoClear is true', () => { + const { unmount } = renderHook(() => useGraphQLRequests(true)) + expect(mock.onNavigatedAddListener).toHaveBeenCalledOnce() + const registeredFn = mock.onNavigatedAddListener.mock.calls[0][0] + unmount() + expect(mock.onNavigatedRemoveListener).toHaveBeenCalledWith(registeredFn) + }) + + it('toggling autoClear from false to true registers the navigation listener', () => { + const { rerender } = renderHook(({ autoClear }) => useGraphQLRequests(autoClear), { + initialProps: { autoClear: false }, + }) + expect(mock.onNavigatedAddListener).not.toHaveBeenCalled() + rerender({ autoClear: true }) + expect(mock.onNavigatedAddListener).toHaveBeenCalledOnce() }) }) diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index 2750295..0355187 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -1,11 +1,16 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { isGraphQLEntry, extractOperationInfo } from './har' import type { HAREntry, GraphQLRequest } from './har' -export function useGraphQLRequests(): GraphQLRequest[] { +export function useGraphQLRequests(autoClear: boolean): { + requests: GraphQLRequest[] + clear: () => void +} { const [requests, setRequests] = useState([]) + const clear = useCallback(() => setRequests([]), []) + useEffect(() => { let counter = 0 function handleRequest(entry: HAREntry) { @@ -30,5 +35,14 @@ export function useGraphQLRequests(): GraphQLRequest[] { } }, []) - return requests + useEffect(() => { + if (!autoClear) return + function handleNavigated() { + setRequests([]) + } + chrome.devtools.network.onNavigated.addListener(handleNavigated) + return () => chrome.devtools.network.onNavigated.removeListener(handleNavigated) + }, [autoClear]) + + return { requests, clear } } From 494305b38343e8dc77661fcbfbfbff00476a7f81 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Mon, 2 Mar 2026 16:31:57 -0500 Subject: [PATCH 04/30] Tweaks to list controls --- entrypoints/devtools-panel/App.css | 18 +++++++ entrypoints/devtools-panel/App.tsx | 48 +++++++++++++++---- .../devtools-panel/__tests__/App.test.tsx | 13 ++--- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index 0aac718..7978b38 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -151,6 +151,24 @@ body { white-space: nowrap; } +.gt-clear-btn { + display: flex; + align-items: center; + justify-content: center; + padding: var(--px-4); + background: none; + border: none; + border-radius: var(--border-radius-4); + color: hsl(var(--color-neutral)); + cursor: pointer; + opacity: 0.75; +} + +.gt-clear-btn:hover { + opacity: 1; + background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + .gt-type-filter { display: flex; gap: var(--px-4); diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index c7a3bca..ba5a756 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -7,13 +7,13 @@ import './App.css' import type { OperationType } from './har' import { useGraphQLRequests } from './useGraphQLRequests' -const FILTER_TYPES: OperationType[] = ['query', 'mutation', 'subscription'] +const FILTER_TYPES: OperationType[] = ['query', 'mutation'] export default function App() { - const [autoClear, setAutoClear] = useState(false) + const [preserveLog, setPreserveLog] = useState(false) const [activeTypes, setActiveTypes] = useState>(new Set(FILTER_TYPES)) - const { requests, clear } = useGraphQLRequests(autoClear) + const { requests, clear } = useGraphQLRequests(!preserveLog) function toggleType(type: OperationType) { setActiveTypes((prev) => { @@ -28,7 +28,9 @@ export default function App() { } const visible = requests.filter( - (r) => r.operationType === 'unknown' || activeTypes.has(r.operationType) + (r) => + !FILTER_TYPES.includes(r.operationType as (typeof FILTER_TYPES)[number]) || + activeTypes.has(r.operationType) ) return ( @@ -36,16 +38,42 @@ export default function App() {
- -
diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index 38620e2..6ba6b25 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -139,19 +139,16 @@ describe('DevTools Panel App', () => { const clear = vi.fn() mockHook([], clear) render() - await userEvent.click(screen.getByRole('button', { name: 'Clear' })) + await userEvent.click(screen.getByRole('button', { name: 'Clear network log' })) expect(clear).toHaveBeenCalledOnce() }) - it('renders three filter buttons (Query, Mutation, Subscription) initially active', () => { + it('renders two filter buttons (Query, Mutation) initially active', () => { mockHook([]) render() expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true') expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: 'Subscription' })).toHaveAttribute( - 'aria-pressed', - 'true' - ) + expect(screen.queryByRole('button', { name: 'Subscription' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument() }) @@ -190,14 +187,14 @@ describe('DevTools Panel App', () => { expect(screen.getByText('GetHero')).toBeInTheDocument() }) - it('auto-clear checkbox is unchecked by default', () => { + it('Preserve log checkbox is unchecked by default', () => { mockHook([]) render() const checkbox = screen.getByRole('checkbox') expect(checkbox).not.toBeChecked() }) - it('clicking the auto-clear checkbox checks it', async () => { + it('clicking the Preserve log checkbox checks it', async () => { mockHook([]) render() const checkbox = screen.getByRole('checkbox') From f4943fd99980a3df0199c2c419fbbcf8bad8fcba Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Mon, 2 Mar 2026 17:08:07 -0500 Subject: [PATCH 05/30] Save display settings --- entrypoints/devtools-panel/App.tsx | 21 +----- .../devtools-panel/__tests__/App.test.tsx | 7 +- .../__tests__/useDevtoolsSettings.test.ts | 70 +++++++++++++++++++ .../devtools-panel/useDevtoolsSettings.ts | 44 ++++++++++++ 4 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts create mode 100644 entrypoints/devtools-panel/useDevtoolsSettings.ts diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index ba5a756..fcb0849 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -1,32 +1,15 @@ import { filesize } from 'filesize' import prettyMs from 'pretty-ms' -import { useState } from 'react' import 'graphiql/style.css' import './App.css' -import type { OperationType } from './har' +import { useDevtoolsSettings, FILTER_TYPES } from './useDevtoolsSettings' import { useGraphQLRequests } from './useGraphQLRequests' -const FILTER_TYPES: OperationType[] = ['query', 'mutation'] - export default function App() { - const [preserveLog, setPreserveLog] = useState(false) - const [activeTypes, setActiveTypes] = useState>(new Set(FILTER_TYPES)) - + const { preserveLog, setPreserveLog, activeTypes, toggleType } = useDevtoolsSettings() const { requests, clear } = useGraphQLRequests(!preserveLog) - function toggleType(type: OperationType) { - setActiveTypes((prev) => { - const next = new Set(prev) - if (next.has(type)) { - next.delete(type) - } else { - next.add(type) - } - return next - }) - } - const visible = requests.filter( (r) => !FILTER_TYPES.includes(r.operationType as (typeof FILTER_TYPES)[number]) || diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index 6ba6b25..1dbd081 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -1,8 +1,9 @@ import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' // @vitest-environment jsdom -import { describe, it, expect, vi, afterEach } from 'vitest' +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' +import { fakeBrowser } from 'wxt/testing/fake-browser' vi.mock('../App.css', () => ({})) vi.mock('graphiql/style.css', () => ({})) @@ -32,6 +33,10 @@ function mockHook(requests: GraphQLRequest[], clear = vi.fn()) { } describe('DevTools Panel App', () => { + beforeEach(() => { + fakeBrowser.reset() + }) + afterEach(() => { cleanup() }) diff --git a/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts new file mode 100644 index 0000000..4ae5b96 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts @@ -0,0 +1,70 @@ +import { renderHook, act } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { fakeBrowser } from 'wxt/testing/fake-browser' + +import { useDevtoolsSettings, FILTER_TYPES } from '../useDevtoolsSettings' + +describe('useDevtoolsSettings', () => { + beforeEach(() => { + fakeBrowser.reset() + }) + + it('defaults to preserveLog=false and all FILTER_TYPES active when storage is empty', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + expect(result.current.preserveLog).toBe(false) + expect(result.current.activeTypes).toEqual(new Set(FILTER_TYPES)) + }) + + it('reads preserveLog=true from storage on init', async () => { + await fakeBrowser.storage.local.set({ 'devtools.preserveLog': true }) + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + expect(result.current.preserveLog).toBe(true) + }) + + it('reads partial activeTypes from storage on init', async () => { + await fakeBrowser.storage.local.set({ 'devtools.activeTypes': ['query'] }) + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + expect(result.current.activeTypes).toEqual(new Set(['query'])) + }) + + it('setPreserveLog(true) updates state and writes to storage', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + await act(async () => { + result.current.setPreserveLog(true) + }) + expect(result.current.preserveLog).toBe(true) + const stored = await fakeBrowser.storage.local.get('devtools.preserveLog') + expect(stored['devtools.preserveLog']).toBe(true) + }) + + it('toggleType deactivates a type and writes updated array to storage', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + await act(async () => { + result.current.toggleType('query') + }) + expect(result.current.activeTypes.has('query')).toBe(false) + const stored = await fakeBrowser.storage.local.get('devtools.activeTypes') + expect(stored['devtools.activeTypes']).toEqual(['mutation']) + }) + + it('toggleType reactivates a type when toggled again', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + await act(async () => { + result.current.toggleType('query') + }) + expect(result.current.activeTypes.has('query')).toBe(false) + await act(async () => { + result.current.toggleType('query') + }) + expect(result.current.activeTypes.has('query')).toBe(true) + const stored = await fakeBrowser.storage.local.get('devtools.activeTypes') + expect(stored['devtools.activeTypes']).toContain('query') + }) +}) diff --git a/entrypoints/devtools-panel/useDevtoolsSettings.ts b/entrypoints/devtools-panel/useDevtoolsSettings.ts new file mode 100644 index 0000000..de81095 --- /dev/null +++ b/entrypoints/devtools-panel/useDevtoolsSettings.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react' + +import { storage } from '#imports' + +import type { OperationType } from './har' + +export const FILTER_TYPES: OperationType[] = ['query', 'mutation'] + +const preserveLogItem = storage.defineItem('local:devtools.preserveLog', { + fallback: false, +}) +const activeTypesItem = storage.defineItem('local:devtools.activeTypes', { + fallback: FILTER_TYPES, +}) + +export function useDevtoolsSettings() { + const [preserveLog, setPreserveLogState] = useState(false) + const [activeTypes, setActiveTypes] = useState>(new Set(FILTER_TYPES)) + + useEffect(() => { + preserveLogItem.getValue().then(setPreserveLogState) + activeTypesItem.getValue().then((types) => setActiveTypes(new Set(types))) + }, []) + + function setPreserveLog(value: boolean) { + setPreserveLogState(value) + preserveLogItem.setValue(value) + } + + function toggleType(type: OperationType) { + setActiveTypes((prev) => { + const next = new Set(prev) + if (next.has(type)) { + next.delete(type) + } else { + next.add(type) + } + activeTypesItem.setValue([...next]) + return next + }) + } + + return { preserveLog, setPreserveLog, activeTypes, toggleType } +} From 10cad6a6da55128cb1b235974377d75e3c068249 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Mon, 2 Mar 2026 17:38:50 -0500 Subject: [PATCH 06/30] Virtualized the table rows --- entrypoints/devtools-panel/App.css | 63 +++++++-------- entrypoints/devtools-panel/App.tsx | 66 ++++++---------- entrypoints/devtools-panel/RequestRow.tsx | 36 +++++++++ .../devtools-panel/__tests__/App.test.tsx | 73 +++-------------- .../__tests__/RequestRow.test.tsx | 79 +++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 28 +++++++ 7 files changed, 208 insertions(+), 139 deletions(-) create mode 100644 entrypoints/devtools-panel/RequestRow.tsx create mode 100644 entrypoints/devtools-panel/__tests__/RequestRow.test.tsx diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index 7978b38..2e25cee 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -16,55 +16,54 @@ body { display: flex; flex-direction: column; height: 100%; + width: 100%; font-family: var(--font-family); font-size: var(--font-size-body); - overflow: auto; + overflow: hidden; } -.gt-network-table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; +.gt-network-header, +.gt-network-row { + display: grid; + grid-template-columns: 20% 10% 10% 10% 50%; + align-items: center; } -.gt-network-table th, -.gt-network-table td { +.gt-network-header > div, +.gt-network-row > div { padding: var(--px-6) var(--px-8); - text-align: left; - border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + box-sizing: border-box; + height: 100%; + display: flex; + align-items: center; } -.gt-network-table th { +.gt-network-header { font-weight: 600; color: hsl(var(--color-neutral)); background-color: hsla(var(--color-neutral), var(--alpha-background-light)); - position: sticky; - top: 0; - z-index: 1; + flex-shrink: 0; } -.gt-network-table th:nth-child(1) { - width: 20%; -} -.gt-network-table th:nth-child(2) { - width: 10%; -} -.gt-network-table th:nth-child(3) { - width: 10%; -} -.gt-network-table th:nth-child(4) { - width: 10%; +.gt-network-row:hover { + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + cursor: pointer; } -.gt-network-table th:nth-child(5) { - width: 50%; + +.gt-network-body { + flex: 1; + min-height: 0; + overflow: hidden; } -.gt-network-table tbody tr:hover { - background-color: hsla(var(--color-neutral), var(--alpha-background-light)); - cursor: pointer; +.gt-network-empty { + text-align: center; + color: hsla(var(--color-neutral), var(--alpha-tertiary)); + padding: var(--px-16); } .gt-status-dot { @@ -119,12 +118,6 @@ body { color: hsl(var(--color-neutral)); } -.gt-network-empty td { - text-align: center; - color: hsla(var(--color-neutral), var(--alpha-tertiary)); - padding: var(--px-16); -} - .gt-devtools-toolbar { display: flex; align-items: center; diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index fcb0849..d02b9e8 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -1,8 +1,8 @@ -import { filesize } from 'filesize' -import prettyMs from 'pretty-ms' +import { List } from 'react-window' import 'graphiql/style.css' import './App.css' +import { RequestRow, ROW_HEIGHT, type RowData } from './RequestRow' import { useDevtoolsSettings, FILTER_TYPES } from './useDevtoolsSettings' import { useGraphQLRequests } from './useGraphQLRequests' @@ -72,48 +72,26 @@ export default function App() { ))}
- - - - - - - - - - - - {visible.length === 0 ? ( - - - - ) : ( - visible.map((req) => ( - - - - - - - - )) - )} - -
OperationStatusSizeTimeURL
No GraphQL requests recorded.
- - {req.operationType === 'mutation' - ? 'M' - : req.operationType === 'subscription' - ? 'S' - : 'Q'} - - {req.operationName} - - - {req.status} - {filesize(req.size)}{prettyMs(req.time)}{req.url}
+
+
Operation
+
Status
+
Size
+
Time
+
URL
+
+
+ {visible.length === 0 ? ( +
No GraphQL requests recorded.
+ ) : ( + + rowComponent={RequestRow} + rowCount={visible.length} + rowHeight={ROW_HEIGHT} + rowProps={{ visible }} + style={{ height: '100%' }} + /> + )} +
) diff --git a/entrypoints/devtools-panel/RequestRow.tsx b/entrypoints/devtools-panel/RequestRow.tsx new file mode 100644 index 0000000..e8129a3 --- /dev/null +++ b/entrypoints/devtools-panel/RequestRow.tsx @@ -0,0 +1,36 @@ +import { filesize } from 'filesize' +import prettyMs from 'pretty-ms' +import type { RowComponentProps } from 'react-window' + +import type { GraphQLRequest } from './har' + +export const ROW_HEIGHT = 32 + +export type RowData = { visible: GraphQLRequest[] } + +export function RequestRow({ index, style, ariaAttributes, visible }: RowComponentProps) { + const req = visible[index] + return ( +
+
+ + {req.operationType === 'mutation' + ? 'M' + : req.operationType === 'subscription' + ? 'S' + : 'Q'} + + {req.operationName} +
+
+ + {req.status} +
+
{filesize(req.size)}
+
{prettyMs(req.time)}
+
{req.url}
+
+ ) +} diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index 1dbd081..3896533 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -1,12 +1,25 @@ import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' // @vitest-environment jsdom +import { cloneElement, type ReactElement } from 'react' import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import '@testing-library/jest-dom/vitest' import { fakeBrowser } from 'wxt/testing/fake-browser' vi.mock('../App.css', () => ({})) vi.mock('graphiql/style.css', () => ({})) +vi.mock('react-window', () => ({ + List: (props: Record) => { + const { rowComponent, rowCount, rowProps } = props as { + rowComponent: (p: object) => ReactElement + rowCount: number + rowProps: object + } + return Array.from({ length: rowCount }, (_, i) => + cloneElement(rowComponent({ ariaAttributes: {}, index: i, style: {}, ...rowProps }), { key: i }) + ) + }, +})) vi.mock('../useGraphQLRequests', () => ({ useGraphQLRequests: vi.fn() })) import App from '../App' @@ -80,66 +93,6 @@ describe('DevTools Panel App', () => { expect(screen.getAllByText('https://api.example.com/graphql')).toHaveLength(2) }) - it('filesize formats size correctly: 512 → "512 B"', () => { - mockHook([makeRequest({ size: 512 })]) - render() - expect(screen.getByText('512 B')).toBeInTheDocument() - }) - - it('prettyMs formats time correctly: 123 → "123ms"', () => { - mockHook([makeRequest({ time: 123 })]) - render() - expect(screen.getByText('123ms')).toBeInTheDocument() - }) - - it('shows Q badge for query operations', () => { - mockHook([makeRequest({ operationType: 'query' })]) - render() - const badge = screen.getByText('Q') - expect(badge).toHaveClass('gt-op-badge--query') - }) - - it('shows M badge for mutation operations', () => { - mockHook([makeRequest({ operationType: 'mutation' })]) - render() - const badge = screen.getByText('M') - expect(badge).toHaveClass('gt-op-badge--mutation') - }) - - it('shows S badge for subscription operations', () => { - mockHook([makeRequest({ operationType: 'subscription' })]) - render() - const badge = screen.getByText('S') - expect(badge).toHaveClass('gt-op-badge--subscription') - }) - - it('shows Q badge for unknown operations', () => { - mockHook([makeRequest({ operationType: 'unknown' })]) - render() - const badge = screen.getByText('Q') - expect(badge).toHaveClass('gt-op-badge--unknown') - }) - - it('shows success dot for 2xx status', () => { - mockHook([makeRequest({ status: 200 })]) - const { container } = render() - expect(container.querySelector('.gt-status-dot--success')).toBeInTheDocument() - expect(container.querySelector('.gt-status-dot--error')).not.toBeInTheDocument() - }) - - it('shows error dot for 4xx status', () => { - mockHook([makeRequest({ status: 400 })]) - const { container } = render() - expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() - expect(container.querySelector('.gt-status-dot--success')).not.toBeInTheDocument() - }) - - it('shows error dot for 5xx status', () => { - mockHook([makeRequest({ status: 500 })]) - const { container } = render() - expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() - }) - it('Clear button calls clear() when clicked', async () => { const clear = vi.fn() mockHook([], clear) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx new file mode 100644 index 0000000..3359865 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, render, screen } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest' +import '@testing-library/jest-dom/vitest' + +import { RequestRow } from '../RequestRow' +import type { GraphQLRequest } from '../har' + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + ...overrides, + } +} + +const ariaAttributes = { 'aria-posinset': 1, 'aria-setsize': 1, role: 'listitem' as const } + +function renderRow(req: GraphQLRequest) { + return render() +} + +describe('RequestRow', () => { + afterEach(() => { + cleanup() + }) + + it('filesize formats size correctly: 512 → "512 B"', () => { + renderRow(makeRequest({ size: 512 })) + expect(screen.getByText('512 B')).toBeInTheDocument() + }) + + it('prettyMs formats time correctly: 123 → "123ms"', () => { + renderRow(makeRequest({ time: 123 })) + expect(screen.getByText('123ms')).toBeInTheDocument() + }) + + it('shows Q badge for query operations', () => { + renderRow(makeRequest({ operationType: 'query' })) + expect(screen.getByText('Q')).toHaveClass('gt-op-badge--query') + }) + + it('shows M badge for mutation operations', () => { + renderRow(makeRequest({ operationType: 'mutation' })) + expect(screen.getByText('M')).toHaveClass('gt-op-badge--mutation') + }) + + it('shows S badge for subscription operations', () => { + renderRow(makeRequest({ operationType: 'subscription' })) + expect(screen.getByText('S')).toHaveClass('gt-op-badge--subscription') + }) + + it('shows Q badge for unknown operations', () => { + renderRow(makeRequest({ operationType: 'unknown' })) + expect(screen.getByText('Q')).toHaveClass('gt-op-badge--unknown') + }) + + it('shows success dot for 2xx status', () => { + const { container } = renderRow(makeRequest({ status: 200 })) + expect(container.querySelector('.gt-status-dot--success')).toBeInTheDocument() + expect(container.querySelector('.gt-status-dot--error')).not.toBeInTheDocument() + }) + + it('shows error dot for 4xx status', () => { + const { container } = renderRow(makeRequest({ status: 400 })) + expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() + expect(container.querySelector('.gt-status-dot--success')).not.toBeInTheDocument() + }) + + it('shows error dot for 5xx status', () => { + const { container } = renderRow(makeRequest({ status: 500 })) + expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() + }) +}) diff --git a/package.json b/package.json index 8042762..dbb3eb3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-window": "^2.2.7", "uuid": "^13.0.0" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/chrome": "^0.1.37", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-window": "^2.0.0", "@vitest/coverage-v8": "^4.0.18", "@wxt-dev/auto-icons": "^1.1.1", "@wxt-dev/module-react": "^1.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efa5ede..aef42bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + react-window: + specifier: ^2.2.7 + version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) uuid: specifier: ^13.0.0 version: 13.0.0 @@ -63,6 +66,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@types/react-window': + specifier: ^2.0.0 + version: 2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@25.3.5)(jiti@2.6.1)(jsdom@28.1.0)) @@ -1564,6 +1570,10 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-window@2.0.0': + resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==} + deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed. + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -2926,6 +2936,12 @@ packages: '@types/react': optional: true + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -4722,6 +4738,13 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-window@2.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react-window: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - react + - react-dom + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -6083,6 +6106,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-window@2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} readable-stream@2.3.8: From be8c3342a98965840abf7932cb88fb08ef6aa4a7 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 12:31:19 -0500 Subject: [PATCH 07/30] Allow resizing of columns --- entrypoints/devtools-panel/App.css | 24 +- entrypoints/devtools-panel/App.tsx | 72 +++++- .../devtools-panel/__tests__/App.test.tsx | 224 ++++++++++++++++-- .../__tests__/RequestRow.test.tsx | 3 +- .../__tests__/useDevtoolsSettings.test.ts | 28 ++- .../devtools-panel/useDevtoolsSettings.ts | 13 +- package.json | 1 + 7 files changed, 336 insertions(+), 29 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index 2e25cee..4143d66 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -25,7 +25,7 @@ body { .gt-network-header, .gt-network-row { display: grid; - grid-template-columns: 20% 10% 10% 10% 50%; + grid-template-columns: var(--gt-col-widths, 200px 100px 100px 100px 1fr); align-items: center; } @@ -36,10 +36,32 @@ body { overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + border-right: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); box-sizing: border-box; height: 100%; display: flex; align-items: center; + position: relative; +} + +.gt-network-header > div:last-child, +.gt-network-row > div:last-child { + border-right: none; +} + +.gt-col-resize-handle { + position: absolute; + right: 0; + top: 0; + width: 4px; + height: 100%; + cursor: col-resize; + z-index: 1; +} + +.gt-col-resize-handle:hover, +.gt-col-resize-handle:active { + background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); } .gt-network-header { diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index d02b9e8..4af67e7 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -1,3 +1,4 @@ +import { useRef, type CSSProperties } from 'react' import { List } from 'react-window' import 'graphiql/style.css' @@ -6,8 +7,11 @@ import { RequestRow, ROW_HEIGHT, type RowData } from './RequestRow' import { useDevtoolsSettings, FILTER_TYPES } from './useDevtoolsSettings' import { useGraphQLRequests } from './useGraphQLRequests' +const MIN_COL_WIDTH = 40 + export default function App() { - const { preserveLog, setPreserveLog, activeTypes, toggleType } = useDevtoolsSettings() + const { preserveLog, setPreserveLog, activeTypes, toggleType, columnWidths, setColumnWidths } = + useDevtoolsSettings() const { requests, clear } = useGraphQLRequests(!preserveLog) const visible = requests.filter( @@ -16,9 +20,37 @@ export default function App() { activeTypes.has(r.operationType) ) + const dragState = useRef<{ colIndex: number; startX: number; startWidth: number } | null>(null) + + function startResize(colIndex: number, e: React.MouseEvent) { + e.preventDefault() + dragState.current = { colIndex, startX: e.clientX, startWidth: columnWidths[colIndex] } + + function onMouseMove(ev: MouseEvent) { + if (!dragState.current) return + const { colIndex: idx, startX, startWidth } = dragState.current + const newWidth = Math.max(MIN_COL_WIDTH, startWidth + (ev.clientX - startX)) + setColumnWidths(columnWidths.map((w, i) => (i === idx ? newWidth : w))) + } + + function onMouseUp() { + dragState.current = null + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + const gridTemplateColumns = [...columnWidths.map((w) => `${w}px`), '1fr'].join(' ') + return (
-
+
-
Operation
-
Status
-
Size
-
Time
+
+ Operation +
startResize(0, e)} + aria-hidden="true" + /> +
+
+ Status +
startResize(1, e)} + aria-hidden="true" + /> +
+
+ Size +
startResize(2, e)} + aria-hidden="true" + /> +
+
+ Time +
startResize(3, e)} + aria-hidden="true" + /> +
URL
diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index 3896533..3049acb 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' // @vitest-environment jsdom import { cloneElement, type ReactElement } from 'react' @@ -16,7 +16,9 @@ vi.mock('react-window', () => ({ rowProps: object } return Array.from({ length: rowCount }, (_, i) => - cloneElement(rowComponent({ ariaAttributes: {}, index: i, style: {}, ...rowProps }), { key: i }) + cloneElement(rowComponent({ ariaAttributes: {}, index: i, style: {}, ...rowProps }), { + key: i, + }) ) }, })) @@ -54,6 +56,10 @@ describe('DevTools Panel App', () => { cleanup() }) + // --------------------------------------------------------------------------- + // Column headers + // --------------------------------------------------------------------------- + it('renders all 5 column headers', () => { mockHook([]) render() @@ -64,35 +70,61 @@ describe('DevTools Panel App', () => { expect(screen.getByText('URL')).toBeInTheDocument() }) - it('shows empty state when hook returns []', () => { + // --------------------------------------------------------------------------- + // Row rendering + // --------------------------------------------------------------------------- + + it('shows empty state when there are no requests', () => { mockHook([]) render() expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument() }) - it('renders a row per request with operation name, status, and URL', () => { + it('renders one row per visible request', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser', operationType: 'mutation' }), + makeRequest({ id: '3', operationName: 'OnUpdate', operationType: 'subscription' }), + ]) + render() + expect(document.querySelectorAll('.gt-network-row')).toHaveLength(3) + }) + + it('renders operation name, status, and URL for each row', () => { mockHook([ makeRequest({ id: '1', operationName: 'GetHero', status: 200, - url: 'https://api.example.com/graphql', + url: 'https://a.example.com/graphql', }), makeRequest({ id: '2', operationName: 'CreateUser', status: 201, - url: 'https://api.example.com/graphql', + url: 'https://b.example.com/graphql', }), ]) render() expect(screen.getByText('GetHero')).toBeInTheDocument() expect(screen.getByText('CreateUser')).toBeInTheDocument() - expect(screen.getAllByText('200')).toHaveLength(1) + expect(screen.getByText('200')).toBeInTheDocument() expect(screen.getByText('201')).toBeInTheDocument() - expect(screen.getAllByText('https://api.example.com/graphql')).toHaveLength(2) + expect(screen.getByText('https://a.example.com/graphql')).toBeInTheDocument() + expect(screen.getByText('https://b.example.com/graphql')).toBeInTheDocument() }) + it('shows success status dot for 2xx responses and error dot for 4xx/5xx', () => { + mockHook([makeRequest({ id: '1', status: 200 }), makeRequest({ id: '2', status: 500 })]) + const { container } = render() + expect(container.querySelectorAll('.gt-status-dot--success')).toHaveLength(1) + expect(container.querySelectorAll('.gt-status-dot--error')).toHaveLength(1) + }) + + // --------------------------------------------------------------------------- + // Clear button + // --------------------------------------------------------------------------- + it('Clear button calls clear() when clicked', async () => { const clear = vi.fn() mockHook([], clear) @@ -101,7 +133,11 @@ describe('DevTools Panel App', () => { expect(clear).toHaveBeenCalledOnce() }) - it('renders two filter buttons (Query, Mutation) initially active', () => { + // --------------------------------------------------------------------------- + // Type filter + // --------------------------------------------------------------------------- + + it('renders Query and Mutation filter buttons, both initially active', () => { mockHook([]) render() expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true') @@ -110,7 +146,7 @@ describe('DevTools Panel App', () => { expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument() }) - it('clicking Query button deactivates it; Query rows hidden, Mutation rows still shown', async () => { + it('deactivating Query hides query rows but keeps mutation rows', async () => { mockHook([ makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }), makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }), @@ -122,8 +158,11 @@ describe('DevTools Panel App', () => { expect(screen.getByText('CreateUser')).toBeInTheDocument() }) - it('clicking Mutation button deactivates it; Mutation rows hidden', async () => { - mockHook([makeRequest({ id: '1', operationType: 'mutation', operationName: 'CreateUser' })]) + it('deactivating Mutation hides mutation rows but keeps query rows', async () => { + mockHook([ + makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }), + makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }), + ]) render() await userEvent.click(screen.getByRole('button', { name: 'Mutation' })) expect(screen.getByRole('button', { name: 'Mutation' })).toHaveAttribute( @@ -131,9 +170,23 @@ describe('DevTools Panel App', () => { 'false' ) expect(screen.queryByText('CreateUser')).not.toBeInTheDocument() + expect(screen.getByText('GetHero')).toBeInTheDocument() + }) + + it('deactivating both filters hides all query/mutation rows and shows empty state', async () => { + mockHook([ + makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }), + makeRequest({ id: '2', operationType: 'mutation', operationName: 'CreateUser' }), + ]) + render() + await userEvent.click(screen.getByRole('button', { name: 'Query' })) + await userEvent.click(screen.getByRole('button', { name: 'Mutation' })) + expect(screen.queryByText('GetHero')).not.toBeInTheDocument() + expect(screen.queryByText('CreateUser')).not.toBeInTheDocument() + expect(screen.getByText('No GraphQL requests recorded.')).toBeInTheDocument() }) - it('re-clicking a deactivated filter button reactivates it', async () => { + it('re-clicking a deactivated filter reactivates it and shows the rows again', async () => { mockHook([makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' })]) render() const queryBtn = screen.getByRole('button', { name: 'Query' }) @@ -145,18 +198,153 @@ describe('DevTools Panel App', () => { expect(screen.getByText('GetHero')).toBeInTheDocument() }) + it('subscription and unknown requests are always shown regardless of filter state', async () => { + mockHook([ + makeRequest({ id: '1', operationType: 'subscription', operationName: 'OnUpdate' }), + makeRequest({ id: '2', operationType: 'unknown', operationName: 'Mystery' }), + makeRequest({ id: '3', operationType: 'query', operationName: 'GetHero' }), + ]) + render() + await userEvent.click(screen.getByRole('button', { name: 'Query' })) + await userEvent.click(screen.getByRole('button', { name: 'Mutation' })) + expect(screen.getByText('OnUpdate')).toBeInTheDocument() + expect(screen.getByText('Mystery')).toBeInTheDocument() + expect(screen.queryByText('GetHero')).not.toBeInTheDocument() + }) + + // --------------------------------------------------------------------------- + // Preserve log + // --------------------------------------------------------------------------- + it('Preserve log checkbox is unchecked by default', () => { mockHook([]) render() - const checkbox = screen.getByRole('checkbox') - expect(checkbox).not.toBeChecked() + expect(screen.getByRole('checkbox')).not.toBeChecked() }) it('clicking the Preserve log checkbox checks it', async () => { mockHook([]) render() - const checkbox = screen.getByRole('checkbox') - await userEvent.click(checkbox) - expect(checkbox).toBeChecked() + await userEvent.click(screen.getByRole('checkbox')) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('calls useGraphQLRequests(true) by default (clear on navigation)', () => { + mockHook([]) + render() + expect(mockUseGraphQLRequests).toHaveBeenCalledWith(true) + }) + + it('calls useGraphQLRequests(false) after enabling Preserve log (keep log on navigation)', async () => { + mockHook([]) + render() + await userEvent.click(screen.getByRole('checkbox')) + expect(mockUseGraphQLRequests).toHaveBeenLastCalledWith(false) + }) + + // --------------------------------------------------------------------------- + // Column resize + // --------------------------------------------------------------------------- + + it('renders 4 resize handles (one per resizable header cell)', () => { + mockHook([]) + render() + expect(document.querySelectorAll('.gt-col-resize-handle')).toHaveLength(4) + }) + + it('initial --gt-col-widths is 200px 100px 100px 100px 1fr', () => { + mockHook([]) + render() + const panel = document.querySelector('.gt-devtools-panel') as HTMLElement + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('200px 100px 100px 100px 1fr') + }) + + it.each([ + { + handleIndex: 0, + label: 'Operation', + defaultWidth: 200, + delta: 50, + expected: '250px 100px 100px 100px 1fr', + }, + { + handleIndex: 1, + label: 'Status', + defaultWidth: 100, + delta: 40, + expected: '200px 140px 100px 100px 1fr', + }, + { + handleIndex: 2, + label: 'Size', + defaultWidth: 100, + delta: 30, + expected: '200px 100px 130px 100px 1fr', + }, + { + handleIndex: 3, + label: 'Time', + defaultWidth: 100, + delta: 20, + expected: '200px 100px 100px 120px 1fr', + }, + ])( + 'dragging handle[$handleIndex] ($label) updates only that column', + ({ handleIndex, delta, expected }) => { + mockHook([]) + render() + const panel = document.querySelector('.gt-devtools-panel') as HTMLElement + const handles = document.querySelectorAll('.gt-col-resize-handle') + + fireEvent.mouseDown(handles[handleIndex], { clientX: 100 }) + fireEvent.mouseMove(document, { clientX: 100 + delta }) + + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe(expected) + } + ) + + it('column width is clamped to MIN_COL_WIDTH (40px) when dragged far left', () => { + mockHook([]) + render() + const panel = document.querySelector('.gt-devtools-panel') as HTMLElement + const handles = document.querySelectorAll('.gt-col-resize-handle') + + fireEvent.mouseDown(handles[0], { clientX: 100 }) + fireEvent.mouseMove(document, { clientX: -500 }) + + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('40px 100px 100px 100px 1fr') + }) + + it('mouseup ends the drag; subsequent mousemoves do not change the width', () => { + mockHook([]) + render() + const panel = document.querySelector('.gt-devtools-panel') as HTMLElement + const handles = document.querySelectorAll('.gt-col-resize-handle') + + fireEvent.mouseDown(handles[0], { clientX: 100 }) + fireEvent.mouseMove(document, { clientX: 150 }) + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr') + + fireEvent.mouseUp(document) + fireEvent.mouseMove(document, { clientX: 300 }) + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr') + }) + + it('each drag measures from its own mousedown origin (not cumulative)', () => { + mockHook([]) + render() + const panel = document.querySelector('.gt-devtools-panel') as HTMLElement + const handles = document.querySelectorAll('.gt-col-resize-handle') + + // First drag: 200 → 250 + fireEvent.mouseDown(handles[0], { clientX: 100 }) + fireEvent.mouseMove(document, { clientX: 150 }) + fireEvent.mouseUp(document) + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('250px 100px 100px 100px 1fr') + + // Second drag starts from new baseline (250px), move +30 → 280 + fireEvent.mouseDown(handles[0], { clientX: 200 }) + fireEvent.mouseMove(document, { clientX: 230 }) + expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('280px 100px 100px 100px 1fr') }) }) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx index 3359865..791674f 100644 --- a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -2,9 +2,8 @@ import { cleanup, render, screen } from '@testing-library/react' // @vitest-environment jsdom import { describe, it, expect, afterEach } from 'vitest' import '@testing-library/jest-dom/vitest' - -import { RequestRow } from '../RequestRow' import type { GraphQLRequest } from '../har' +import { RequestRow } from '../RequestRow' function makeRequest(overrides: Partial = {}): GraphQLRequest { return { diff --git a/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts index 4ae5b96..72ca54d 100644 --- a/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts +++ b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react' import { describe, it, expect, beforeEach } from 'vitest' import { fakeBrowser } from 'wxt/testing/fake-browser' -import { useDevtoolsSettings, FILTER_TYPES } from '../useDevtoolsSettings' +import { useDevtoolsSettings, FILTER_TYPES, DEFAULT_COLUMN_WIDTHS } from '../useDevtoolsSettings' describe('useDevtoolsSettings', () => { beforeEach(() => { @@ -67,4 +67,30 @@ describe('useDevtoolsSettings', () => { const stored = await fakeBrowser.storage.local.get('devtools.activeTypes') expect(stored['devtools.activeTypes']).toContain('query') }) + + it('columnWidths defaults to DEFAULT_COLUMN_WIDTHS when storage is empty', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + expect(result.current.columnWidths).toEqual(DEFAULT_COLUMN_WIDTHS) + }) + + it('reads persisted columnWidths from storage on init', async () => { + const customWidths = [300, 150, 80, 120] + await fakeBrowser.storage.local.set({ 'devtools.columnWidths': customWidths }) + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + expect(result.current.columnWidths).toEqual(customWidths) + }) + + it('setColumnWidths updates state and writes to storage', async () => { + const { result } = renderHook(() => useDevtoolsSettings()) + await act(async () => {}) + const newWidths = [250, 80, 90, 110] + await act(async () => { + result.current.setColumnWidths(newWidths) + }) + expect(result.current.columnWidths).toEqual(newWidths) + const stored = await fakeBrowser.storage.local.get('devtools.columnWidths') + expect(stored['devtools.columnWidths']).toEqual(newWidths) + }) }) diff --git a/entrypoints/devtools-panel/useDevtoolsSettings.ts b/entrypoints/devtools-panel/useDevtoolsSettings.ts index de81095..c9920a0 100644 --- a/entrypoints/devtools-panel/useDevtoolsSettings.ts +++ b/entrypoints/devtools-panel/useDevtoolsSettings.ts @@ -5,6 +5,7 @@ import { storage } from '#imports' import type { OperationType } from './har' export const FILTER_TYPES: OperationType[] = ['query', 'mutation'] +export const DEFAULT_COLUMN_WIDTHS = [200, 100, 100, 100] const preserveLogItem = storage.defineItem('local:devtools.preserveLog', { fallback: false, @@ -12,14 +13,19 @@ const preserveLogItem = storage.defineItem('local:devtools.preserveLog' const activeTypesItem = storage.defineItem('local:devtools.activeTypes', { fallback: FILTER_TYPES, }) +const columnWidthsItem = storage.defineItem('local:devtools.columnWidths', { + fallback: DEFAULT_COLUMN_WIDTHS, +}) export function useDevtoolsSettings() { const [preserveLog, setPreserveLogState] = useState(false) const [activeTypes, setActiveTypes] = useState>(new Set(FILTER_TYPES)) + const [columnWidths, setColumnWidthsState] = useState(DEFAULT_COLUMN_WIDTHS) useEffect(() => { preserveLogItem.getValue().then(setPreserveLogState) activeTypesItem.getValue().then((types) => setActiveTypes(new Set(types))) + columnWidthsItem.getValue().then(setColumnWidthsState) }, []) function setPreserveLog(value: boolean) { @@ -40,5 +46,10 @@ export function useDevtoolsSettings() { }) } - return { preserveLog, setPreserveLog, activeTypes, toggleType } + function setColumnWidths(widths: number[]) { + setColumnWidthsState(widths) + columnWidthsItem.setValue(widths) + } + + return { preserveLog, setPreserveLog, activeTypes, toggleType, columnWidths, setColumnWidths } } diff --git a/package.json b/package.json index dbb3eb3..4409c5a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test": "pnpm run compile && vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "pnpm run build && playwright test", + "fullcheck": "pnpm run compile && pnpm run lint && pnpm run format && pnpm run test:coverage", "postinstall": "wxt prepare" }, "dependencies": { From 7823508cdbfa4feb0d71bffb89a2ef745266d2bc Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 13:23:55 -0500 Subject: [PATCH 08/30] Added a context menu to request list items --- entrypoints/devtools-panel/App.tsx | 23 ++- entrypoints/devtools-panel/ContextMenu.css | 26 +++ entrypoints/devtools-panel/ContextMenu.tsx | 92 ++++++++++ entrypoints/devtools-panel/RequestRow.tsx | 23 ++- .../devtools-panel/__tests__/App.test.tsx | 26 +++ .../__tests__/ContextMenu.test.tsx | 166 ++++++++++++++++++ .../__tests__/RequestRow.test.tsx | 27 ++- .../devtools-panel/__tests__/har.test.ts | 155 +++++++++++++++- .../__tests__/useGraphQLRequests.test.ts | 86 +++++++-- entrypoints/devtools-panel/har.ts | 51 ++++++ .../devtools-panel/useGraphQLRequests.ts | 21 ++- 11 files changed, 668 insertions(+), 28 deletions(-) create mode 100644 entrypoints/devtools-panel/ContextMenu.css create mode 100644 entrypoints/devtools-panel/ContextMenu.tsx create mode 100644 entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index 4af67e7..c01d469 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -1,8 +1,10 @@ -import { useRef, type CSSProperties } from 'react' +import { useRef, useState, type CSSProperties } from 'react' import { List } from 'react-window' import 'graphiql/style.css' import './App.css' +import { ContextMenu } from './ContextMenu' +import type { GraphQLRequest } from './har' import { RequestRow, ROW_HEIGHT, type RowData } from './RequestRow' import { useDevtoolsSettings, FILTER_TYPES } from './useDevtoolsSettings' import { useGraphQLRequests } from './useGraphQLRequests' @@ -20,6 +22,12 @@ export default function App() { activeTypes.has(r.operationType) ) + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + request: GraphQLRequest + } | null>(null) + const dragState = useRef<{ colIndex: number; startX: number; startWidth: number } | null>(null) function startResize(colIndex: number, e: React.MouseEvent) { @@ -147,12 +155,23 @@ export default function App() { rowComponent={RequestRow} rowCount={visible.length} rowHeight={ROW_HEIGHT} - rowProps={{ visible }} + rowProps={{ + visible, + onContextMenu: (req, x, y) => setContextMenu({ x, y, request: req }), + }} style={{ height: '100%' }} /> )}
+ {contextMenu && ( + setContextMenu(null)} + /> + )}
) } diff --git a/entrypoints/devtools-panel/ContextMenu.css b/entrypoints/devtools-panel/ContextMenu.css new file mode 100644 index 0000000..87e844f --- /dev/null +++ b/entrypoints/devtools-panel/ContextMenu.css @@ -0,0 +1,26 @@ +.gt-context-menu { + position: fixed; + z-index: 1000; + background: hsla(var(--color-base), 1); + border: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + border-radius: var(--border-radius-4); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + min-width: 160px; +} + +.gt-context-menu-item { + display: block; + width: 100%; + padding: var(--px-6) var(--px-12); + font-family: var(--font-family); + font-size: var(--font-size-body); + cursor: pointer; + text-align: left; + border: none; + background: none; + color: inherit; +} + +.gt-context-menu-item:hover { + background: hsla(var(--color-neutral), var(--alpha-background-light)); +} diff --git a/entrypoints/devtools-panel/ContextMenu.tsx b/entrypoints/devtools-panel/ContextMenu.tsx new file mode 100644 index 0000000..683b26e --- /dev/null +++ b/entrypoints/devtools-panel/ContextMenu.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react' + +import './ContextMenu.css' +import type { GraphQLRequest } from './har' + +type Props = { + x: number + y: number + request: GraphQLRequest + onClose: () => void +} + +function prettyJson(value: string): string { + try { + return JSON.stringify(JSON.parse(value), null, 2) + } catch { + return value + } +} + +function copyAndClose(text: string, onClose: () => void) { + navigator.clipboard + .writeText(text) + .finally(onClose) + .catch(() => {}) +} + +export function ContextMenu({ x, y, request, onClose }: Props) { + const menuRef = useRef(null) + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + function handleMouseDown(e: MouseEvent) { + // Ignore right-clicks — they're handled by the contextmenu event + if (e.button === 2) return + if (!menuRef.current?.contains(e.target as Node)) onClose() + } + function handleContextMenu(e: MouseEvent) { + // Prevent the browser's native menu while ours is open. + // Right-clicking on a row fires that row's onContextMenu first, + // which updates state to show the new row's menu. + e.preventDefault() + } + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('contextmenu', handleContextMenu) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('contextmenu', handleContextMenu) + } + }, [onClose]) + + return ( +
+ + + {request.variables !== undefined && ( + + )} + {request.response && ( + + )} +
+ ) +} diff --git a/entrypoints/devtools-panel/RequestRow.tsx b/entrypoints/devtools-panel/RequestRow.tsx index e8129a3..e26fb5b 100644 --- a/entrypoints/devtools-panel/RequestRow.tsx +++ b/entrypoints/devtools-panel/RequestRow.tsx @@ -6,12 +6,29 @@ import type { GraphQLRequest } from './har' export const ROW_HEIGHT = 32 -export type RowData = { visible: GraphQLRequest[] } +export type RowData = { + visible: GraphQLRequest[] + onContextMenu: (req: GraphQLRequest, x: number, y: number) => void +} -export function RequestRow({ index, style, ariaAttributes, visible }: RowComponentProps) { +export function RequestRow({ + index, + style, + ariaAttributes, + visible, + onContextMenu, +}: RowComponentProps) { const req = visible[index] return ( -
+
{ + e.preventDefault() + onContextMenu(req, e.clientX, e.clientY) + }} + >
{req.operationType === 'mutation' diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index 3049acb..ce59209 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -7,6 +7,7 @@ import '@testing-library/jest-dom/vitest' import { fakeBrowser } from 'wxt/testing/fake-browser' vi.mock('../App.css', () => ({})) +vi.mock('../ContextMenu.css', () => ({})) vi.mock('graphiql/style.css', () => ({})) vi.mock('react-window', () => ({ List: (props: Record) => { @@ -39,6 +40,7 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { size: 512, time: 123, url: 'https://api.example.com/graphql', + query: 'query GetHero { hero { name } }', ...overrides, } } @@ -347,4 +349,28 @@ describe('DevTools Panel App', () => { fireEvent.mouseMove(document, { clientX: 230 }) expect(panel.style.getPropertyValue('--gt-col-widths')).toBe('280px 100px 100px 100px 1fr') }) + + // --------------------------------------------------------------------------- + // Context menu + // --------------------------------------------------------------------------- + + it('right-clicking a row opens the context menu', () => { + mockHook([makeRequest()]) + render() + const row = document.querySelector('.gt-network-row') as HTMLElement + fireEvent.contextMenu(row, { clientX: 100, clientY: 200 }) + expect(screen.getByRole('menu')).toBeInTheDocument() + expect(screen.getByText('Copy URL')).toBeInTheDocument() + expect(screen.getByText('Copy Query')).toBeInTheDocument() + }) + + it('context menu closes when clicking outside it', () => { + mockHook([makeRequest()]) + render() + const row = document.querySelector('.gt-network-row') as HTMLElement + fireEvent.contextMenu(row, { clientX: 100, clientY: 200 }) + expect(screen.getByRole('menu')).toBeInTheDocument() + fireEvent.mouseDown(document.body, { button: 0 }) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx new file mode 100644 index 0000000..fbffa4d --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' + +vi.mock('../ContextMenu.css', () => ({})) + +import { ContextMenu } from '../ContextMenu' +import type { GraphQLRequest } from '../har' + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + query: 'query GetHero { hero { name } }', + variables: '{\n "id": "1"\n}', + response: '{"data":{"hero":{"name":"Luke"}}}', + ...overrides, + } +} + +function renderMenu(req: GraphQLRequest, onClose = vi.fn()) { + return render() +} + +describe('ContextMenu', () => { + let writeText: ReturnType + + beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('always shows Copy URL, Copy Query, Copy Variables, Copy Response when all fields present', () => { + renderMenu(makeRequest()) + expect(screen.getByText('Copy URL')).toBeInTheDocument() + expect(screen.getByText('Copy Query')).toBeInTheDocument() + expect(screen.getByText('Copy Variables')).toBeInTheDocument() + expect(screen.getByText('Copy Response')).toBeInTheDocument() + }) + + it('hides Copy Variables when variables is undefined', () => { + renderMenu(makeRequest({ variables: undefined })) + expect(screen.queryByText('Copy Variables')).not.toBeInTheDocument() + }) + + it('hides Copy Response when response is undefined', () => { + renderMenu(makeRequest({ response: undefined })) + expect(screen.queryByText('Copy Response')).not.toBeInTheDocument() + }) + + it('hides Copy Response when response is empty string', () => { + renderMenu(makeRequest({ response: '' })) + expect(screen.queryByText('Copy Response')).not.toBeInTheDocument() + }) + + it('Copy URL copies the url and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy URL')) + expect(writeText).toHaveBeenCalledWith('https://api.example.com/graphql') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Query copies the query string and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy Query')) + expect(writeText).toHaveBeenCalledWith('query GetHero { hero { name } }') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Variables copies the pretty-printed variables and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ variables: '{"id":"1"}' }), onClose) + fireEvent.click(screen.getByText('Copy Variables')) + expect(writeText).toHaveBeenCalledWith('{\n "id": "1"\n}') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Response copies the pretty-printed response and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy Response')) + expect(writeText).toHaveBeenCalledWith( + '{\n "data": {\n "hero": {\n "name": "Luke"\n }\n }\n}' + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('pressing Escape calls onClose', () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('left-clicking outside the menu calls onClose', () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.mouseDown(document.body, { button: 0 }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('right-clicking outside the menu does not call onClose', () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.mouseDown(document.body, { button: 2 }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('left-clicking inside the menu does not call onClose', () => { + const onClose = vi.fn() + const { container } = renderMenu(makeRequest(), onClose) + const menu = container.querySelector('.gt-context-menu') as HTMLElement + fireEvent.mouseDown(menu, { button: 0 }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('menu is positioned at the given x/y coordinates', () => { + const { container } = renderMenu(makeRequest()) + const menu = container.querySelector('.gt-context-menu') as HTMLElement + expect(menu.style.left).toBe('100px') + expect(menu.style.top).toBe('200px') + }) + + it('copies non-JSON variables as-is when pretty-printing fails', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ variables: 'not json' }), onClose) + fireEvent.click(screen.getByText('Copy Variables')) + expect(writeText).toHaveBeenCalledWith('not json') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('copies non-JSON response as-is when pretty-printing fails', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ response: 'plain text response' }), onClose) + fireEvent.click(screen.getByText('Copy Response')) + expect(writeText).toHaveBeenCalledWith('plain text response') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('calls onClose even when clipboard write rejects', async () => { + writeText.mockRejectedValue(new Error('clipboard unavailable')) + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy Query')) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('contextmenu event on the document is prevented while the menu is open', () => { + renderMenu(makeRequest()) + const prevented = fireEvent.contextMenu(document.body) + expect(prevented).toBe(false) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx index 791674f..ca57595 100644 --- a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -1,6 +1,6 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, fireEvent } from '@testing-library/react' // @vitest-environment jsdom -import { describe, it, expect, afterEach } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import '@testing-library/jest-dom/vitest' import type { GraphQLRequest } from '../har' import { RequestRow } from '../RequestRow' @@ -14,14 +14,23 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { size: 512, time: 123, url: 'https://api.example.com/graphql', + query: 'query GetHero { hero { name } }', ...overrides, } } const ariaAttributes = { 'aria-posinset': 1, 'aria-setsize': 1, role: 'listitem' as const } -function renderRow(req: GraphQLRequest) { - return render() +function renderRow(req: GraphQLRequest, onContextMenu = vi.fn()) { + return render( + + ) } describe('RequestRow', () => { @@ -75,4 +84,14 @@ describe('RequestRow', () => { const { container } = renderRow(makeRequest({ status: 500 })) expect(container.querySelector('.gt-status-dot--error')).toBeInTheDocument() }) + + it('right-click calls onContextMenu with the request and mouse coordinates', () => { + const onContextMenu = vi.fn() + const req = makeRequest() + const { container } = renderRow(req, onContextMenu) + const row = container.firstChild as HTMLElement + fireEvent.contextMenu(row, { clientX: 100, clientY: 200 }) + expect(onContextMenu).toHaveBeenCalledOnce() + expect(onContextMenu).toHaveBeenCalledWith(req, 100, 200) + }) }) diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts index 8734ab6..48c3d0c 100644 --- a/entrypoints/devtools-panel/__tests__/har.test.ts +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { isGraphQLEntry, extractOperationInfo } from '../har' +import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables } from '../har' import type { HAREntry } from '../har' function makeEntry(overrides: Partial = {}): HAREntry { @@ -18,6 +18,7 @@ function makeEntry(overrides: Partial = {}): HAREntry { ...overrides.response, }, time: 123, + getContent: () => {}, ...overrides, } } @@ -393,3 +394,155 @@ describe('extractOperationInfo', () => { }) }) }) + +describe('extractQueryAndVariables', () => { + it('POST with query only → query returned, no variables', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ + query: 'query GetHero { hero { name } }', + variables: undefined, + }) + }) + + it('POST with query and object variables → both returned, variables pretty-printed', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify({ + query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', + variables: { id: '1' }, + }), + }, + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ + query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', + variables: '{\n "id": "1"\n}', + }) + }) + + it('POST with null variables → no variables', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '{ hero }', variables: null }) }, + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '{ hero }', variables: undefined }) + }) + + it('POST with non-object variables (string) → no variables', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify({ query: '{ hero }', variables: 'not-an-object' }) }, + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '{ hero }', variables: undefined }) + }) + + it('POST with invalid JSON body → empty query', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: 'not-json' }, + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '' }) + }) + + it('POST with no body → empty query', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '' }) + }) + + it('GET with query param only → query returned, no variables', () => { + const query = encodeURIComponent('query GetHero { hero { name } }') + const entry = makeEntry({ + request: { + method: 'GET', + url: `https://example.com/graphql?query=${query}`, + headers: [], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ + query: 'query GetHero { hero { name } }', + variables: undefined, + }) + }) + + it('GET with query and variables params → both returned', () => { + const query = encodeURIComponent('query GetHero($id: ID!) { hero(id: $id) { name } }') + const variables = encodeURIComponent(JSON.stringify({ id: '1' })) + const entry = makeEntry({ + request: { + method: 'GET', + url: `https://example.com/graphql?query=${query}&variables=${variables}`, + headers: [], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ + query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', + variables: '{\n "id": "1"\n}', + }) + }) + + it('GET with invalid variables param → query returned, no variables', () => { + const query = encodeURIComponent('{ hero }') + const entry = makeEntry({ + request: { + method: 'GET', + url: `https://example.com/graphql?query=${query}&variables=not-json`, + headers: [], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ + query: '{ hero }', + variables: undefined, + }) + }) + + it('GET with no query param → empty query', () => { + const entry = makeEntry({ + request: { + method: 'GET', + url: 'https://example.com/graphql?foo=bar', + headers: [], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '' }) + }) + + it('unrecognized method → empty query', () => { + const entry = makeEntry({ + request: { + method: 'PUT', + url: 'https://example.com/graphql', + headers: [], + }, + }) + expect(extractQueryAndVariables(entry)).toEqual({ query: '' }) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index 1a49df5..2943da2 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -59,17 +59,22 @@ function makeChromeMock() { } } -function makeGraphQLEntry(overrides: Partial = {}): HAREntry { +function makeGraphQLEntry( + requestOverrides: Partial = {}, + responseBody = '{"data":{"hero":{"name":"Luke"}}}', + encoding = '' +): HAREntry { return { request: { method: 'POST', url: 'https://api.example.com/graphql', headers: [{ name: 'content-type', value: 'application/json' }], postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, - ...overrides, + ...requestOverrides, }, response: { status: 200, content: { size: 512 } }, time: 123, + getContent: (cb) => cb(responseBody, encoding), } } @@ -82,6 +87,7 @@ function makeNonGraphQLEntry(): HAREntry { }, response: { status: 200, content: { size: 100 } }, time: 50, + getContent: (cb) => cb('', ''), } } @@ -114,9 +120,9 @@ describe('useGraphQLRequests', () => { expect(mock.removeListener).toHaveBeenCalledWith(registeredFn) }) - it('adds a GraphQLRequest entry when a matching HAR entry fires', () => { + it('adds a GraphQLRequest entry when a matching HAR entry fires', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) @@ -128,20 +134,50 @@ describe('useGraphQLRequests', () => { size: 512, time: 123, url: 'https://api.example.com/graphql', + query: 'query GetHero { hero { name } }', + response: '{"data":{"hero":{"name":"Luke"}}}', }) }) - it('ignores non-GraphQL entries', () => { + it('stores variables when present in the request', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { + mock.fire( + makeGraphQLEntry({ + postData: { + text: JSON.stringify({ + query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', + variables: { id: '1' }, + }), + }, + }) + ) + }) + expect(result.current.requests[0]).toMatchObject({ + query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', + variables: '{\n "id": "1"\n}', + }) + }) + + it('response is undefined when getContent returns empty string', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + mock.fire(makeGraphQLEntry({}, '')) + }) + expect(result.current.requests[0].response).toBeUndefined() + }) + + it('ignores non-GraphQL entries', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { mock.fire(makeNonGraphQLEntry()) }) expect(result.current.requests).toHaveLength(0) }) - it('multiple entries accumulate in order', () => { + it('multiple entries accumulate in order', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) mock.fire( makeGraphQLEntry({ @@ -162,9 +198,9 @@ describe('useGraphQLRequests', () => { }) }) - it('id increments monotonically across multiple entries', () => { + it('id increments monotonically across multiple entries', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) mock.fire(makeGraphQLEntry()) mock.fire(makeGraphQLEntry()) @@ -172,9 +208,9 @@ describe('useGraphQLRequests', () => { expect(result.current.requests.map((r) => r.id)).toEqual(['1', '2', '3']) }) - it('clear() empties the request list', () => { + it('clear() empties the request list', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) mock.fire(makeGraphQLEntry()) }) @@ -185,9 +221,9 @@ describe('useGraphQLRequests', () => { expect(result.current.requests).toHaveLength(0) }) - it('navigation event clears requests when autoClear is true', () => { + it('navigation event clears requests when autoClear is true', async () => { const { result } = renderHook(() => useGraphQLRequests(true)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) @@ -197,9 +233,9 @@ describe('useGraphQLRequests', () => { expect(result.current.requests).toHaveLength(0) }) - it('navigation event does NOT clear when autoClear is false', () => { + it('navigation event does NOT clear when autoClear is false', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) - act(() => { + await act(async () => { mock.fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) @@ -217,6 +253,24 @@ describe('useGraphQLRequests', () => { expect(mock.onNavigatedRemoveListener).toHaveBeenCalledWith(registeredFn) }) + it('decodes base64-encoded response content', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + const json = '{"data":{"hero":{"name":"Luke"}}}' + await act(async () => { + mock.fire(makeGraphQLEntry({}, btoa(json), 'base64')) + }) + expect(result.current.requests[0].response).toBe(json) + }) + + it('falls back to raw content when base64 decoding fails', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + const invalid = '!!!not-valid-base64!!!' + await act(async () => { + mock.fire(makeGraphQLEntry({}, invalid, 'base64')) + }) + expect(result.current.requests[0].response).toBe(invalid) + }) + it('toggling autoClear from false to true registers the navigation listener', () => { const { rerender } = renderHook(({ autoClear }) => useGraphQLRequests(autoClear), { initialProps: { autoClear: false }, diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index 1c334e7..b47605f 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -12,6 +12,7 @@ export type HAREntry = { content: { size: number } } time: number + getContent(callback: (content: string, encoding: string) => void): void } export type OperationType = 'query' | 'mutation' | 'subscription' | 'unknown' @@ -24,6 +25,9 @@ export type GraphQLRequest = { size: number time: number url: string + query: string + variables?: string + response?: string } export function isGraphQLEntry(entry: HAREntry): boolean { @@ -52,6 +56,53 @@ export function isGraphQLEntry(entry: HAREntry): boolean { return false } +export type QueryAndVariables = { + query: string + variables?: string +} + +export function extractQueryAndVariables(entry: HAREntry): QueryAndVariables { + const { method, url, postData } = entry.request + + if (method === 'POST' && postData?.text) { + try { + const body = JSON.parse(postData.text) + if (typeof body.query === 'string') { + const variables = + body.variables !== null && typeof body.variables === 'object' + ? JSON.stringify(body.variables, null, 2) + : undefined + return { query: body.query, variables } + } + } catch { + // fall through + } + } + + if (method === 'GET') { + try { + const params = new URL(url).searchParams + const query = params.get('query') + if (query) { + const variablesParam = params.get('variables') + let variables: string | undefined + if (variablesParam) { + try { + variables = JSON.stringify(JSON.parse(variablesParam), null, 2) + } catch { + // not valid JSON, skip + } + } + return { query, variables } + } + } catch { + // fall through + } + } + + return { query: '' } +} + export type OperationInfo = { operationName: string operationType: OperationType diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index 0355187..b9c2337 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' -import { isGraphQLEntry, extractOperationInfo } from './har' +import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables } from './har' import type { HAREntry, GraphQLRequest } from './har' export function useGraphQLRequests(autoClear: boolean): { @@ -13,9 +13,23 @@ export function useGraphQLRequests(autoClear: boolean): { useEffect(() => { let counter = 0 - function handleRequest(entry: HAREntry) { + async function handleRequest(entry: HAREntry) { if (!isGraphQLEntry(entry)) return const { operationName, operationType } = extractOperationInfo(entry) + const { query, variables } = extractQueryAndVariables(entry) + const responseText = await new Promise((resolve) => { + entry.getContent((content, encoding) => { + if (encoding === 'base64') { + try { + resolve(atob(content)) + } catch { + resolve(content) + } + } else { + resolve(content) + } + }) + }) setRequests((prev) => [ ...prev, { @@ -26,6 +40,9 @@ export function useGraphQLRequests(autoClear: boolean): { size: entry.response.content.size, time: entry.time, url: entry.request.url, + query, + variables, + response: responseText || undefined, }, ]) } From fc5376fb7920981fdfd72f20fd45df834a84e5e0 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 13:59:41 -0500 Subject: [PATCH 09/30] Added curl command copying to request list menu --- entrypoints/devtools-panel/ContextMenu.tsx | 8 ++ .../devtools-panel/__tests__/App.test.tsx | 2 + .../__tests__/ContextMenu.test.tsx | 11 ++ .../__tests__/RequestRow.test.tsx | 2 + .../devtools-panel/__tests__/har.test.ts | 107 +++++++++++++++++- .../__tests__/useGraphQLRequests.test.ts | 2 + entrypoints/devtools-panel/har.ts | 35 ++++++ .../devtools-panel/useGraphQLRequests.ts | 2 + 8 files changed, 167 insertions(+), 2 deletions(-) diff --git a/entrypoints/devtools-panel/ContextMenu.tsx b/entrypoints/devtools-panel/ContextMenu.tsx index 683b26e..da186cd 100644 --- a/entrypoints/devtools-panel/ContextMenu.tsx +++ b/entrypoints/devtools-panel/ContextMenu.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import './ContextMenu.css' +import { buildCurlCommand } from './har' import type { GraphQLRequest } from './har' type Props = { @@ -87,6 +88,13 @@ export function ContextMenu({ x, y, request, onClose }: Props) { Copy Response )} +
) } diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index ce59209..e5bc726 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -40,6 +40,8 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { size: 512, time: 123, url: 'https://api.example.com/graphql', + method: 'POST', + headers: [], query: 'query GetHero { hero { name } }', ...overrides, } diff --git a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx index fbffa4d..6855692 100644 --- a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx +++ b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx @@ -17,6 +17,8 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { size: 512, time: 123, url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], query: 'query GetHero { hero { name } }', variables: '{\n "id": "1"\n}', response: '{"data":{"hero":{"name":"Luke"}}}', @@ -134,6 +136,15 @@ describe('ContextMenu', () => { expect(menu.style.top).toBe('200px') }) + it('Copy as cURL copies a curl command and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy as cURL')) + expect(writeText).toHaveBeenCalledOnce() + expect(writeText.mock.calls[0][0]).toMatch(/^curl -X /) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + it('copies non-JSON variables as-is when pretty-printing fails', async () => { const onClose = vi.fn() renderMenu(makeRequest({ variables: 'not json' }), onClose) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx index ca57595..a5663d6 100644 --- a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -14,6 +14,8 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { size: 512, time: 123, url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], query: 'query GetHero { hero { name } }', ...overrides, } diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts index 48c3d0c..d2004ee 100644 --- a/entrypoints/devtools-panel/__tests__/har.test.ts +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from 'vitest' -import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables } from '../har' -import type { HAREntry } from '../har' +import { + isGraphQLEntry, + extractOperationInfo, + extractQueryAndVariables, + buildCurlCommand, +} from '../har' +import type { HAREntry, GraphQLRequest } from '../har' function makeEntry(overrides: Partial = {}): HAREntry { return { @@ -546,3 +551,101 @@ describe('extractQueryAndVariables', () => { expect(extractQueryAndVariables(entry)).toEqual({ query: '' }) }) }) + +describe('buildCurlCommand', () => { + function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + query: 'query GetHero { hero { name } }', + ...overrides, + } + } + + it('POST with headers and no variables → correct -X, -H flag and --data-raw without variables key', () => { + const cmd = buildCurlCommand(makeRequest()) + expect(cmd).toBe( + "curl -X POST 'https://api.example.com/graphql'" + + " -H 'content-type: application/json'" + + ' --data-raw \'{"query":"query GetHero { hero { name } }"}\'' + ) + }) + + it('POST with variables → --data-raw body includes parsed variables object', () => { + const cmd = buildCurlCommand(makeRequest({ variables: '{\n "id": "1"\n}' })) + expect(cmd).toContain( + '--data-raw \'{"query":"query GetHero { hero { name } }","variables":{"id":"1"}}\'' + ) + }) + + it('POST with non-JSON variables → variables key omitted from body', () => { + const cmd = buildCurlCommand(makeRequest({ variables: 'not json' })) + expect(cmd).toContain('--data-raw \'{"query":"query GetHero { hero { name } }"}\'') + expect(cmd).not.toContain('variables') + }) + + it('GET request → no --data-raw, URL used as-is', () => { + const cmd = buildCurlCommand( + makeRequest({ + method: 'GET', + url: 'https://api.example.com/graphql?query=%7B%20hero%20%7D', + headers: [], + }) + ) + expect(cmd).toBe("curl -X GET 'https://api.example.com/graphql?query=%7B%20hero%20%7D'") + expect(cmd).not.toContain('--data-raw') + }) + + it('skips headers with : prefix (HTTP/2 pseudo-headers)', () => { + const cmd = buildCurlCommand( + makeRequest({ headers: [{ name: ':authority', value: 'api.example.com' }] }) + ) + expect(cmd).not.toContain(':authority') + }) + + it('skips headers with sec- prefix (browser security headers)', () => { + const cmd = buildCurlCommand( + makeRequest({ headers: [{ name: 'sec-fetch-site', value: 'same-origin' }] }) + ) + expect(cmd).not.toContain('sec-fetch-site') + }) + + it('skips content-length header', () => { + const cmd = buildCurlCommand( + makeRequest({ headers: [{ name: 'content-length', value: '42' }] }) + ) + expect(cmd).not.toContain('content-length') + }) + + it('includes authorization and custom headers', () => { + const cmd = buildCurlCommand( + makeRequest({ + headers: [ + { name: 'authorization', value: 'Bearer token123' }, + { name: 'x-custom', value: 'value' }, + ], + }) + ) + expect(cmd).toContain("-H 'authorization: Bearer token123'") + expect(cmd).toContain("-H 'x-custom: value'") + }) + + it('escapes single quotes in header values', () => { + const cmd = buildCurlCommand( + makeRequest({ headers: [{ name: 'x-header', value: "it's a value" }] }) + ) + expect(cmd).toContain("-H 'x-header: it'\\''s a value'") + }) + + it('escapes single quotes in the URL', () => { + const cmd = buildCurlCommand(makeRequest({ url: "https://example.com/it's", headers: [] })) + expect(cmd).toContain("curl -X POST 'https://example.com/it'\\''s'") + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index 2943da2..e9ef44b 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -134,6 +134,8 @@ describe('useGraphQLRequests', () => { size: 512, time: 123, url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], query: 'query GetHero { hero { name } }', response: '{"data":{"hero":{"name":"Luke"}}}', }) diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index b47605f..33aa3b0 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -25,6 +25,8 @@ export type GraphQLRequest = { size: number time: number url: string + method: string + headers: Array<{ name: string; value: string }> query: string variables?: string response?: string @@ -163,3 +165,36 @@ function parseOperation(query: string): OperationInfo { } return { operationName: 'Anonymous', operationType: 'unknown' } } + +const SKIPPED_HEADERS = new Set(['content-length']) +const SKIPPED_HEADER_PREFIXES = [':', 'sec-'] + +function shellEscape(value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'" +} + +export function buildCurlCommand(request: GraphQLRequest): string { + const parts = ['curl', `-X ${request.method.toUpperCase()}`, shellEscape(request.url)] + + for (const { name, value } of request.headers) { + const lower = name.toLowerCase() + if (SKIPPED_HEADERS.has(lower) || SKIPPED_HEADER_PREFIXES.some((p) => lower.startsWith(p))) { + continue + } + parts.push(`-H ${shellEscape(`${name}: ${value}`)}`) + } + + if (request.method.toUpperCase() === 'POST') { + const body: Record = { query: request.query } + if (request.variables) { + try { + body.variables = JSON.parse(request.variables) + } catch { + // variables couldn't be parsed; omit from body + } + } + parts.push(`--data-raw ${shellEscape(JSON.stringify(body))}`) + } + + return parts.join(' ') +} diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index b9c2337..dade522 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -40,6 +40,8 @@ export function useGraphQLRequests(autoClear: boolean): { size: entry.response.content.size, time: entry.time, url: entry.request.url, + method: entry.request.method, + headers: entry.request.headers, query, variables, response: responseText || undefined, From 476e612b67f408d279b7a7098bb4e83d42a9af32 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 14:14:54 -0500 Subject: [PATCH 10/30] Added request details modal --- entrypoints/devtools-panel/App.tsx | 6 + entrypoints/devtools-panel/RequestModal.css | 89 +++++++++++++++ entrypoints/devtools-panel/RequestModal.tsx | 58 ++++++++++ entrypoints/devtools-panel/RequestRow.tsx | 3 + .../devtools-panel/__tests__/App.test.tsx | 31 +++++ .../__tests__/RequestModal.test.tsx | 107 ++++++++++++++++++ .../__tests__/RequestRow.test.tsx | 13 ++- 7 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 entrypoints/devtools-panel/RequestModal.css create mode 100644 entrypoints/devtools-panel/RequestModal.tsx create mode 100644 entrypoints/devtools-panel/__tests__/RequestModal.test.tsx diff --git a/entrypoints/devtools-panel/App.tsx b/entrypoints/devtools-panel/App.tsx index c01d469..757be25 100644 --- a/entrypoints/devtools-panel/App.tsx +++ b/entrypoints/devtools-panel/App.tsx @@ -5,6 +5,7 @@ import 'graphiql/style.css' import './App.css' import { ContextMenu } from './ContextMenu' import type { GraphQLRequest } from './har' +import { RequestModal } from './RequestModal' import { RequestRow, ROW_HEIGHT, type RowData } from './RequestRow' import { useDevtoolsSettings, FILTER_TYPES } from './useDevtoolsSettings' import { useGraphQLRequests } from './useGraphQLRequests' @@ -27,6 +28,7 @@ export default function App() { y: number request: GraphQLRequest } | null>(null) + const [selectedRequest, setSelectedRequest] = useState(null) const dragState = useRef<{ colIndex: number; startX: number; startWidth: number } | null>(null) @@ -158,6 +160,7 @@ export default function App() { rowProps={{ visible, onContextMenu: (req, x, y) => setContextMenu({ x, y, request: req }), + onClick: (req) => setSelectedRequest(req), }} style={{ height: '100%' }} /> @@ -172,6 +175,9 @@ export default function App() { onClose={() => setContextMenu(null)} /> )} + {selectedRequest && ( + setSelectedRequest(null)} /> + )}
) } diff --git a/entrypoints/devtools-panel/RequestModal.css b/entrypoints/devtools-panel/RequestModal.css new file mode 100644 index 0000000..5b4b111 --- /dev/null +++ b/entrypoints/devtools-panel/RequestModal.css @@ -0,0 +1,89 @@ +.gt-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + display: flex; + align-items: center; + justify-content: center; +} + +.gt-modal { + width: 95vw; + height: 90vh; + background: hsla(var(--color-base), 1); + border: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + border-radius: var(--border-radius-4); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.gt-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--px-8) var(--px-12); + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + flex-shrink: 0; +} + +.gt-modal-title { + font-family: var(--font-family); + font-size: var(--font-size-body); + font-weight: 600; + color: hsl(var(--color-neutral)); +} + +.gt-modal-close { + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + color: hsl(var(--color-neutral)); + opacity: 0.75; + padding: var(--px-4); + border-radius: var(--border-radius-4); +} + +.gt-modal-close:hover { + opacity: 1; + background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + +.gt-modal-tabs { + display: flex; + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + flex-shrink: 0; +} + +.gt-modal-tab { + padding: var(--px-6) var(--px-12); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: var(--font-family); + font-size: var(--font-size-body); + color: hsl(var(--color-neutral)); + opacity: 0.7; + margin-bottom: -1px; +} + +.gt-modal-tab:hover { + opacity: 1; +} + +.gt-modal-tab--active { + opacity: 1; + border-bottom-color: hsl(var(--color-primary)); + font-weight: 600; +} + +.gt-modal-content { + flex: 1; + min-height: 0; + overflow: auto; +} diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx new file mode 100644 index 0000000..036d901 --- /dev/null +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react' + +import './RequestModal.css' +import type { GraphQLRequest } from './har' + +type Tab = 'headers' | 'request' | 'response' + +type Props = { + request: GraphQLRequest + onClose: () => void +} + +const TABS: Tab[] = ['headers', 'request', 'response'] + +export function RequestModal({ request, onClose }: Props) { + const [activeTab, setActiveTab] = useState('headers') + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + return ( +
+
e.stopPropagation()} + > +
+ {request.operationName} + +
+
+ {TABS.map((tab) => ( + + ))} +
+
+
+
+ ) +} diff --git a/entrypoints/devtools-panel/RequestRow.tsx b/entrypoints/devtools-panel/RequestRow.tsx index e26fb5b..338f55b 100644 --- a/entrypoints/devtools-panel/RequestRow.tsx +++ b/entrypoints/devtools-panel/RequestRow.tsx @@ -9,6 +9,7 @@ export const ROW_HEIGHT = 32 export type RowData = { visible: GraphQLRequest[] onContextMenu: (req: GraphQLRequest, x: number, y: number) => void + onClick: (req: GraphQLRequest) => void } export function RequestRow({ @@ -17,6 +18,7 @@ export function RequestRow({ ariaAttributes, visible, onContextMenu, + onClick, }: RowComponentProps) { const req = visible[index] return ( @@ -24,6 +26,7 @@ export function RequestRow({ style={style} className="gt-network-row" {...ariaAttributes} + onClick={() => onClick(req)} onContextMenu={(e) => { e.preventDefault() onContextMenu(req, e.clientX, e.clientY) diff --git a/entrypoints/devtools-panel/__tests__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx index e5bc726..f914a58 100644 --- a/entrypoints/devtools-panel/__tests__/App.test.tsx +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -8,6 +8,7 @@ import { fakeBrowser } from 'wxt/testing/fake-browser' vi.mock('../App.css', () => ({})) vi.mock('../ContextMenu.css', () => ({})) +vi.mock('../RequestModal.css', () => ({})) vi.mock('graphiql/style.css', () => ({})) vi.mock('react-window', () => ({ List: (props: Record) => { @@ -375,4 +376,34 @@ describe('DevTools Panel App', () => { fireEvent.mouseDown(document.body, { button: 0 }) expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) + + // --------------------------------------------------------------------------- + // Request modal + // --------------------------------------------------------------------------- + + it('left-clicking a row opens the request modal', () => { + mockHook([makeRequest()]) + render() + const row = document.querySelector('.gt-network-row') as HTMLElement + fireEvent.click(row) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('modal shows the operation name of the clicked row', () => { + mockHook([makeRequest({ operationName: 'GetHero' })]) + render() + const row = document.querySelector('.gt-network-row') as HTMLElement + fireEvent.click(row) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero') + }) + + it('pressing Escape closes the modal', () => { + mockHook([makeRequest()]) + render() + const row = document.querySelector('.gt-network-row') as HTMLElement + fireEvent.click(row) + expect(screen.getByRole('dialog')).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx new file mode 100644 index 0000000..6ba51c8 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' + +vi.mock('../RequestModal.css', () => ({})) + +import type { GraphQLRequest } from '../har' +import { RequestModal } from '../RequestModal' + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + query: 'query GetHero { hero { name } }', + ...overrides, + } +} + +function renderModal(req: GraphQLRequest = makeRequest(), onClose = vi.fn()) { + return render() +} + +describe('RequestModal', () => { + afterEach(() => { + cleanup() + }) + + it('renders with role="dialog" and aria-modal="true"', () => { + renderModal() + const dialog = screen.getByRole('dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + it('displays the operation name in the header', () => { + renderModal(makeRequest({ operationName: 'MyQuery' })) + expect(screen.getByText('MyQuery')).toBeInTheDocument() + }) + + it('renders all three tabs', () => { + renderModal() + expect(screen.getByRole('tab', { name: 'Headers' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Request' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Response' })).toBeInTheDocument() + }) + + it('Headers tab is active by default', () => { + renderModal() + expect(screen.getByRole('tab', { name: 'Headers' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Request' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tab', { name: 'Response' })).toHaveAttribute('aria-selected', 'false') + }) + + it('clicking Request tab makes it active', () => { + renderModal() + fireEvent.click(screen.getByRole('tab', { name: 'Request' })) + expect(screen.getByRole('tab', { name: 'Request' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Headers' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tab', { name: 'Response' })).toHaveAttribute('aria-selected', 'false') + }) + + it('clicking Response tab makes it active', () => { + renderModal() + fireEvent.click(screen.getByRole('tab', { name: 'Response' })) + expect(screen.getByRole('tab', { name: 'Response' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Headers' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getByRole('tab', { name: 'Request' })).toHaveAttribute('aria-selected', 'false') + }) + + it('pressing Escape calls onClose', () => { + const onClose = vi.fn() + renderModal(makeRequest(), onClose) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('clicking the backdrop calls onClose', () => { + const onClose = vi.fn() + const { container } = renderModal(makeRequest(), onClose) + const backdrop = container.querySelector('.gt-modal-backdrop') as HTMLElement + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('clicking the × button calls onClose', () => { + const onClose = vi.fn() + renderModal(makeRequest(), onClose) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('clicking inside the modal does not call onClose', () => { + const onClose = vi.fn() + const { container } = renderModal(makeRequest(), onClose) + const modal = container.querySelector('.gt-modal') as HTMLElement + fireEvent.click(modal) + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx index a5663d6..30bf3e9 100644 --- a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -23,7 +23,7 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { const ariaAttributes = { 'aria-posinset': 1, 'aria-setsize': 1, role: 'listitem' as const } -function renderRow(req: GraphQLRequest, onContextMenu = vi.fn()) { +function renderRow(req: GraphQLRequest, onContextMenu = vi.fn(), onClick = vi.fn()) { return render( ) } @@ -96,4 +97,14 @@ describe('RequestRow', () => { expect(onContextMenu).toHaveBeenCalledOnce() expect(onContextMenu).toHaveBeenCalledWith(req, 100, 200) }) + + it('left-click calls onClick with the request', () => { + const onClick = vi.fn() + const req = makeRequest() + const { container } = renderRow(req, vi.fn(), onClick) + const row = container.firstChild as HTMLElement + fireEvent.click(row) + expect(onClick).toHaveBeenCalledOnce() + expect(onClick).toHaveBeenCalledWith(req) + }) }) From 0f0f1c8a3d66dfaa4c86708e59c95d61b5f30644 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 14:46:49 -0500 Subject: [PATCH 11/30] Populate the headers tab --- entrypoints/devtools-panel/HeadersTable.tsx | 75 +++++++ entrypoints/devtools-panel/RequestModal.css | 80 +++++++ entrypoints/devtools-panel/RequestModal.tsx | 10 +- .../__tests__/HeadersTable.test.tsx | 197 ++++++++++++++++++ .../__tests__/RequestModal.test.tsx | 18 ++ .../__tests__/useGraphQLRequests.test.ts | 25 ++- entrypoints/devtools-panel/har.ts | 2 + .../devtools-panel/useGraphQLRequests.ts | 1 + 8 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 entrypoints/devtools-panel/HeadersTable.tsx create mode 100644 entrypoints/devtools-panel/__tests__/HeadersTable.test.tsx diff --git a/entrypoints/devtools-panel/HeadersTable.tsx b/entrypoints/devtools-panel/HeadersTable.tsx new file mode 100644 index 0000000..bc25017 --- /dev/null +++ b/entrypoints/devtools-panel/HeadersTable.tsx @@ -0,0 +1,75 @@ +function copyText(text: string) { + navigator.clipboard.writeText(text).catch(() => {}) +} + +const CopyIcon = () => ( + +) + +type Props = { + title: string + headers?: Array<{ name: string; value: string }> +} + +export function HeadersTable({ title, headers }: Props) { + const visible = headers?.filter(({ name }) => !name.startsWith(':')) + return ( +
+

+ {title} + {visible && visible.length > 0 && ( + + )} +

+ {visible && visible.length > 0 ? ( + + + + + + + + + {visible.map(({ name, value }) => ( + + + + + + ))} + +
NameValue +
{name}{value} + +
+ ) : ( +

No headers

+ )} +
+ ) +} diff --git a/entrypoints/devtools-panel/RequestModal.css b/entrypoints/devtools-panel/RequestModal.css index 5b4b111..891b677 100644 --- a/entrypoints/devtools-panel/RequestModal.css +++ b/entrypoints/devtools-panel/RequestModal.css @@ -87,3 +87,83 @@ min-height: 0; overflow: auto; } + +.gt-modal-headers { + padding: var(--px-8) var(--px-12); + display: flex; + flex-direction: column; + gap: var(--px-16); +} + +.gt-headers-section-title { + display: flex; + align-items: center; + gap: var(--px-8); + font-family: var(--font-family); + font-size: var(--font-size-body); + font-weight: 600; + color: hsl(var(--color-neutral)); + margin: 0 0 var(--px-6) 0; +} + +.gt-headers-copy-btn { + background: none; + border: none; + cursor: pointer; + padding: var(--px-4); + color: hsl(var(--color-neutral)); + opacity: 0.6; + border-radius: var(--border-radius-4); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.gt-headers-copy-btn:hover { + opacity: 1; + background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + +.gt-headers-row-actions { + width: 1px; + white-space: nowrap; + word-break: normal; +} + +.gt-headers-row .gt-headers-copy-btn { + visibility: hidden; +} + +.gt-headers-row:hover .gt-headers-copy-btn { + visibility: visible; +} + +.gt-headers-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-family); + font-size: var(--font-size-body); +} + +.gt-headers-table th { + text-align: left; + padding: var(--px-6) var(--px-8); + font-weight: 600; + color: hsl(var(--color-neutral)); + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); +} + +.gt-headers-table td { + padding: var(--px-6) var(--px-8); + border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); + vertical-align: top; + word-break: break-all; +} + +.gt-headers-table td:first-child { + white-space: nowrap; + color: hsl(var(--color-neutral)); + font-weight: 500; + width: 30%; +} diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx index 036d901..ac25ef1 100644 --- a/entrypoints/devtools-panel/RequestModal.tsx +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import './RequestModal.css' import type { GraphQLRequest } from './har' +import { HeadersTable } from './HeadersTable' type Tab = 'headers' | 'request' | 'response' @@ -51,7 +52,14 @@ export function RequestModal({ request, onClose }: Props) { ))}
-
+
+ {activeTab === 'headers' && ( +
+ + +
+ )} +
) diff --git a/entrypoints/devtools-panel/__tests__/HeadersTable.test.tsx b/entrypoints/devtools-panel/__tests__/HeadersTable.test.tsx new file mode 100644 index 0000000..1e2a4e7 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/HeadersTable.test.tsx @@ -0,0 +1,197 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { HeadersTable } from '../HeadersTable' + +describe('HeadersTable', () => { + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('renders the given title', () => { + render() + expect(screen.getByText('Request Headers')).toBeInTheDocument() + }) + + it('renders header names and values in table rows', () => { + render( + + ) + expect(screen.getByText('content-type')).toBeInTheDocument() + expect(screen.getByText('application/json')).toBeInTheDocument() + expect(screen.getByText('authorization')).toBeInTheDocument() + expect(screen.getByText('Bearer token')).toBeInTheDocument() + }) + + it('renders Name and Value column headings when headers are present', () => { + render() + expect(screen.getByText('Name')).toBeInTheDocument() + expect(screen.getByText('Value')).toBeInTheDocument() + }) + + it('shows "No headers" when headers is undefined', () => { + render() + expect(screen.getByText('No headers')).toBeInTheDocument() + }) + + it('shows "No headers" when headers is an empty array', () => { + render() + expect(screen.getByText('No headers')).toBeInTheDocument() + }) + + it('does not render a table when headers is undefined', () => { + const { container } = render() + expect(container.querySelector('table')).not.toBeInTheDocument() + }) + + it('filters out headers with a ":" prefix', () => { + render( + + ) + expect(screen.queryByText(':authority')).not.toBeInTheDocument() + expect(screen.queryByText(':method')).not.toBeInTheDocument() + expect(screen.getByText('content-type')).toBeInTheDocument() + }) + + it('shows "No headers" when all headers are filtered out', () => { + render( + + ) + expect(screen.getByText('No headers')).toBeInTheDocument() + }) + + describe('copy all headers button', () => { + it('renders the copy-all button when visible headers exist', () => { + render( + + ) + expect(screen.getByTitle('Copy all headers')).toBeInTheDocument() + }) + + it('does not render the copy-all button when there are no headers', () => { + render() + expect(screen.queryByTitle('Copy all headers')).not.toBeInTheDocument() + }) + + it('does not render the copy-all button when all headers are filtered out', () => { + render( + + ) + expect(screen.queryByTitle('Copy all headers')).not.toBeInTheDocument() + }) + + it('clicking copy-all writes all visible headers to clipboard', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + render( + + ) + + fireEvent.click(screen.getByTitle('Copy all headers')) + + expect(writeText).toHaveBeenCalledWith( + 'content-type: application/json\nauthorization: Bearer token' + ) + }) + + it('copy-all excludes pseudo-headers', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + render( + + ) + + fireEvent.click(screen.getByTitle('Copy all headers')) + + expect(writeText).toHaveBeenCalledWith('content-type: application/json') + }) + }) + + describe('per-row copy button', () => { + it('renders a copy button for each header row', () => { + render( + + ) + expect(screen.getAllByTitle('Copy header')).toHaveLength(2) + }) + + it('clicking a row copy button writes that header to clipboard', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + render( + + ) + + fireEvent.click(screen.getAllByTitle('Copy header')[0]) + + expect(writeText).toHaveBeenCalledWith('content-type: application/json') + }) + + it('clicking the second row copy button writes the correct header', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + + render( + + ) + + fireEvent.click(screen.getAllByTitle('Copy header')[1]) + + expect(writeText).toHaveBeenCalledWith('authorization: Bearer token') + }) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx index 6ba51c8..f7db1fa 100644 --- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -20,6 +20,7 @@ function makeRequest(overrides: Partial = {}): GraphQLRequest { method: 'POST', headers: [{ name: 'content-type', value: 'application/json' }], query: 'query GetHero { hero { name } }', + responseHeaders: [{ name: 'x-request-id', value: 'abc123' }], ...overrides, } } @@ -104,4 +105,21 @@ describe('RequestModal', () => { fireEvent.click(modal) expect(onClose).not.toHaveBeenCalled() }) + + // --------------------------------------------------------------------------- + // Headers tab content + // --------------------------------------------------------------------------- + + it('Headers tab renders Request Headers and Response Headers sections', () => { + renderModal() + expect(screen.getByText('Request Headers')).toBeInTheDocument() + expect(screen.getByText('Response Headers')).toBeInTheDocument() + }) + + it('Headers tab content is not visible when a different tab is active', () => { + renderModal() + fireEvent.click(screen.getByRole('tab', { name: 'Request' })) + expect(screen.queryByText('Request Headers')).not.toBeInTheDocument() + expect(screen.queryByText('Response Headers')).not.toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index e9ef44b..9a3f936 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -62,7 +62,8 @@ function makeChromeMock() { function makeGraphQLEntry( requestOverrides: Partial = {}, responseBody = '{"data":{"hero":{"name":"Luke"}}}', - encoding = '' + encoding = '', + responseHeaders?: Array<{ name: string; value: string }> ): HAREntry { return { request: { @@ -72,7 +73,7 @@ function makeGraphQLEntry( postData: { text: JSON.stringify({ query: 'query GetHero { hero { name } }' }) }, ...requestOverrides, }, - response: { status: 200, content: { size: 512 } }, + response: { status: 200, content: { size: 512 }, headers: responseHeaders }, time: 123, getContent: (cb) => cb(responseBody, encoding), } @@ -264,6 +265,26 @@ describe('useGraphQLRequests', () => { expect(result.current.requests[0].response).toBe(json) }) + it('captures response headers when present in the HAR entry', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + const headers = [ + { name: 'content-type', value: 'application/json' }, + { name: 'x-request-id', value: 'abc123' }, + ] + await act(async () => { + mock.fire(makeGraphQLEntry({}, undefined, '', headers)) + }) + expect(result.current.requests[0].responseHeaders).toEqual(headers) + }) + + it('responseHeaders is undefined when not present in the HAR entry', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + mock.fire(makeGraphQLEntry()) + }) + expect(result.current.requests[0].responseHeaders).toBeUndefined() + }) + it('falls back to raw content when base64 decoding fails', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) const invalid = '!!!not-valid-base64!!!' diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index 33aa3b0..f74176c 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -10,6 +10,7 @@ export type HAREntry = { response: { status: number content: { size: number } + headers?: Array<{ name: string; value: string }> } time: number getContent(callback: (content: string, encoding: string) => void): void @@ -30,6 +31,7 @@ export type GraphQLRequest = { query: string variables?: string response?: string + responseHeaders?: Array<{ name: string; value: string }> } export function isGraphQLEntry(entry: HAREntry): boolean { diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index dade522..83e41c8 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -45,6 +45,7 @@ export function useGraphQLRequests(autoClear: boolean): { query, variables, response: responseText || undefined, + responseHeaders: entry.response.headers, }, ]) } From c3b55d8f0497ad6f3600b876bad09991390a378a Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Tue, 3 Mar 2026 23:40:04 -0500 Subject: [PATCH 12/30] Populate the request tab --- entrypoints/devtools-panel/RequestModal.tsx | 2 + entrypoints/devtools-panel/RequestTab.css | 69 ++++++ entrypoints/devtools-panel/RequestTab.tsx | 171 ++++++++++++++ .../__tests__/RequestModal.test.tsx | 18 ++ .../__tests__/RequestTab.test.tsx | 215 ++++++++++++++++++ .../devtools-panel/__tests__/har.test.ts | 115 ++++++++++ entrypoints/devtools-panel/har.ts | 19 +- .../devtools-panel/useGraphQLRequests.ts | 3 +- package.json | 2 + pnpm-lock.yaml | 168 ++++++++++++++ 10 files changed, 779 insertions(+), 3 deletions(-) create mode 100644 entrypoints/devtools-panel/RequestTab.css create mode 100644 entrypoints/devtools-panel/RequestTab.tsx create mode 100644 entrypoints/devtools-panel/__tests__/RequestTab.test.tsx diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx index ac25ef1..212634b 100644 --- a/entrypoints/devtools-panel/RequestModal.tsx +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import './RequestModal.css' import type { GraphQLRequest } from './har' import { HeadersTable } from './HeadersTable' +import { RequestTab } from './RequestTab' type Tab = 'headers' | 'request' | 'response' @@ -59,6 +60,7 @@ export function RequestModal({ request, onClose }: Props) {
)} + {activeTab === 'request' && }
diff --git a/entrypoints/devtools-panel/RequestTab.css b/entrypoints/devtools-panel/RequestTab.css new file mode 100644 index 0000000..307d6a3 --- /dev/null +++ b/entrypoints/devtools-panel/RequestTab.css @@ -0,0 +1,69 @@ +.gt-request-tab { + padding: var(--px-8) var(--px-12); + display: flex; + flex-direction: column; + gap: var(--px-16); +} + +.gt-query-block { + margin: 0; + padding: var(--px-8) var(--px-12); + border-radius: var(--border-radius-4); + background: hsla(var(--color-neutral), var(--alpha-background-light)); + color: hsl(var(--color-neutral)); + font-family: var(--font-family); + font-size: var(--font-size-body); + overflow-x: auto; + white-space: pre; +} + +/* Raw query toggle button */ +.gt-raw-toggle { + background: none; + border: 1px solid transparent; + cursor: pointer; + padding: 1px var(--px-6); + border-radius: var(--border-radius-4); + font-family: var(--font-family); + font-size: var(--font-size-body); + color: hsl(var(--color-neutral)); + opacity: 0.6; + line-height: 1.4; +} + +.gt-raw-toggle:hover { + opacity: 1; + background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + +.gt-raw-toggle--active { + opacity: 1; + border-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); +} + +/* Highlight.js token colors — all grounded in GraphiQL CSS variables */ +.gt-query-block .hljs-keyword { + color: hsl(var(--color-primary)); + font-weight: 600; +} + +.gt-query-block .hljs-variable { + color: hsl(var(--color-primary)); + opacity: 0.8; +} + +.gt-query-block .hljs-comment { + opacity: 0.5; + font-style: italic; +} + +/* react-json-view container — background comes from theme but we make it transparent; + wrap it in the same box style as the query block */ +.gt-json-block { + padding: var(--px-8) var(--px-12); + border-radius: var(--border-radius-4); + background: hsla(var(--color-neutral), var(--alpha-background-light)); + font-family: var(--font-family); + font-size: var(--font-size-body); +} diff --git a/entrypoints/devtools-panel/RequestTab.tsx b/entrypoints/devtools-panel/RequestTab.tsx new file mode 100644 index 0000000..2102e66 --- /dev/null +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -0,0 +1,171 @@ +import ReactJsonView from '@microlink/react-json-view' +import { parse, print } from 'graphql' +import hljs from 'highlight.js/lib/core' +import graphql from 'highlight.js/lib/languages/graphql' +import { useMemo, useState } from 'react' + +import './RequestTab.css' +import type { GraphQLRequest } from './har' + +hljs.registerLanguage('graphql', graphql) + +function formatQuery(raw: string): string { + try { + return print(parse(raw)) + } catch { + return raw + } +} + +function parseJsonObject(str: string): Record | null { + try { + const parsed = JSON.parse(str) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed + return null + } catch { + return null + } +} + +function copyText(text: string) { + navigator.clipboard.writeText(text).catch(() => {}) +} + +const CopyIcon = () => ( + +) + +type Props = { + request: GraphQLRequest +} + +export function RequestTab({ request }: Props) { + const isDark = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)').matches, []) + const [showRaw, setShowRaw] = useState(false) + + const formattedQuery = useMemo(() => formatQuery(request.query), [request.query]) + const displayedQuery = showRaw ? request.query : formattedQuery + + const highlightedQuery = useMemo( + () => hljs.highlight(formattedQuery, { language: 'graphql' }).value, + [formattedQuery] + ) + + const parsedVariables = useMemo( + () => (request.variables ? parseJsonObject(request.variables) : null), + [request.variables] + ) + + const parsedExtensions = useMemo( + () => (request.extensions ? parseJsonObject(request.extensions) : null), + [request.extensions] + ) + + const jsonTheme = isDark ? 'monokai' : 'rjv-default' + const jsonViewStyle = { background: 'transparent', padding: '0' } + + return ( +
+
+

+ Query + + +

+
+          {showRaw ? (
+            {request.query}
+          ) : (
+            
+          )}
+        
+
+ + {request.variables && ( +
+

+ Variables + +

+ {parsedVariables ? ( +
+ +
+ ) : ( +
+              {request.variables}
+            
+ )} +
+ )} + + {request.extensions && ( +
+

+ Extensions + +

+ {parsedExtensions ? ( +
+ +
+ ) : ( +
+              {request.extensions}
+            
+ )} +
+ )} +
+ ) +} diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx index f7db1fa..4552c37 100644 --- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -4,6 +4,9 @@ import { describe, it, expect, afterEach, vi } from 'vitest' import '@testing-library/jest-dom/vitest' vi.mock('../RequestModal.css', () => ({})) +vi.mock('../RequestTab', () => ({ + RequestTab: () =>
, +})) import type { GraphQLRequest } from '../har' import { RequestModal } from '../RequestModal' @@ -122,4 +125,19 @@ describe('RequestModal', () => { expect(screen.queryByText('Request Headers')).not.toBeInTheDocument() expect(screen.queryByText('Response Headers')).not.toBeInTheDocument() }) + + // --------------------------------------------------------------------------- + // Request tab content + // --------------------------------------------------------------------------- + + it('Request tab renders RequestTab component when active', () => { + renderModal() + fireEvent.click(screen.getByRole('tab', { name: 'Request' })) + expect(screen.getByTestId('request-tab-mock')).toBeInTheDocument() + }) + + it('RequestTab is not in DOM when a different tab is active', () => { + renderModal() + expect(screen.queryByTestId('request-tab-mock')).not.toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx new file mode 100644 index 0000000..43af78e --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx @@ -0,0 +1,215 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' + +vi.mock('../RequestTab.css', () => ({})) +vi.mock('@microlink/react-json-view', () => ({ + default: ({ src }: { src: object }) => ( +
+ ), +})) + +import type { GraphQLRequest } from '../har' +import { RequestTab } from '../RequestTab' + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + query: 'query GetHero { hero { name } }', + ...overrides, + } +} + +describe('RequestTab', () => { + beforeEach(() => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false })) + ) + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + // --------------------------------------------------------------------------- + // Query section + // --------------------------------------------------------------------------- + + it('renders a "Query" section heading', () => { + render() + expect(screen.getByText('Query')).toBeInTheDocument() + }) + + it('displays the query text in a code block', () => { + render() + const code = document.querySelector('pre code') + expect(code).not.toBeNull() + expect(code!.textContent).toContain('GetHero') + }) + + it('formats a compact query using print()', () => { + render() + const code = document.querySelector('pre code') + // print() adds whitespace/newlines around braces + expect(code!.textContent).toMatch(/GetHero\s*\{/) + expect(code!.textContent).toContain('\n') + }) + + it('falls back to raw query string when the query is invalid GraphQL', () => { + const raw = '!@#invalid graphql' + render() + const code = document.querySelector('pre code') + expect(code!.textContent).toBe(raw) + }) + + it('copy button is present in the Query section', () => { + render() + expect(screen.getByTitle('Copy query')).toBeInTheDocument() + }) + + it('clicking copy query button writes the formatted query to clipboard', () => { + render() + fireEvent.click(screen.getByTitle('Copy query')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining('GetHero')) + }) + + it('renders a "Raw" toggle button in the Query section', () => { + render() + expect(screen.getByRole('button', { name: 'Raw' })).toBeInTheDocument() + }) + + it('Raw toggle is inactive by default', () => { + render() + expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active') + }) + + it('clicking Raw toggle shows the unformatted query without syntax highlighting', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + const code = document.querySelector('pre code') + expect(code!.textContent).toBe(compactQuery) + expect(code!.innerHTML).toBe(compactQuery) + }) + + it('clicking Raw toggle marks the button as active', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active') + }) + + it('clicking Raw toggle again reverts to formatted query', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + const code = document.querySelector('pre code') + expect(code!.textContent).not.toBe(compactQuery) + expect(code!.textContent).toContain('\n') + }) + + it('copy button copies the raw query when Raw toggle is active', () => { + const compactQuery = 'query GetHero{hero{name}}' + render() + fireEvent.click(screen.getByRole('button', { name: 'Raw' })) + fireEvent.click(screen.getByTitle('Copy query')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(compactQuery) + }) + + // --------------------------------------------------------------------------- + // Variables section + // --------------------------------------------------------------------------- + + it('Variables section is absent when variables is undefined', () => { + render() + expect(screen.queryByText('Variables')).not.toBeInTheDocument() + }) + + it('renders "Variables" heading when variables is present', () => { + render() + expect(screen.getByText('Variables')).toBeInTheDocument() + }) + + it('renders ReactJsonView with parsed variables when variables is valid JSON object', () => { + render() + const jsonView = screen.getByTestId('json-view') + expect(jsonView).toBeInTheDocument() + expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ id: '1' }) + }) + + it('falls back to
 display when variables is not valid JSON', () => {
+    render()
+    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+    // The fallback pre block contains the raw string
+    const pres = document.querySelectorAll('pre')
+    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+    expect(fallback).toBeDefined()
+  })
+
+  it('copy button is present in the Variables section', () => {
+    render()
+    expect(screen.getByTitle('Copy variables')).toBeInTheDocument()
+  })
+
+  it('clicking copy variables button writes the variables string to clipboard', () => {
+    const variables = '{\n  "id": "1"\n}'
+    render()
+    fireEvent.click(screen.getByTitle('Copy variables'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(variables)
+  })
+
+  // ---------------------------------------------------------------------------
+  // Extensions section
+  // ---------------------------------------------------------------------------
+
+  it('Extensions section is absent when extensions is undefined', () => {
+    render()
+    expect(screen.queryByText('Extensions')).not.toBeInTheDocument()
+  })
+
+  it('renders "Extensions" heading when extensions is present', () => {
+    render()
+    expect(screen.getByText('Extensions')).toBeInTheDocument()
+  })
+
+  it('renders ReactJsonView with parsed extensions when extensions is valid JSON object', () => {
+    render()
+    const jsonView = screen.getByTestId('json-view')
+    expect(jsonView).toBeInTheDocument()
+    expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ tracing: true })
+  })
+
+  it('falls back to 
 display when extensions is not valid JSON', () => {
+    render()
+    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+    const pres = document.querySelectorAll('pre')
+    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+    expect(fallback).toBeDefined()
+  })
+
+  it('copy button is present in the Extensions section', () => {
+    render()
+    expect(screen.getByTitle('Copy extensions')).toBeInTheDocument()
+  })
+
+  it('clicking copy extensions button writes the extensions string to clipboard', () => {
+    const extensions = '{\n  "tracing": true\n}'
+    render()
+    fireEvent.click(screen.getByTitle('Copy extensions'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(extensions)
+  })
+})
diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts
index d2004ee..6dd9c2f 100644
--- a/entrypoints/devtools-panel/__tests__/har.test.ts
+++ b/entrypoints/devtools-panel/__tests__/har.test.ts
@@ -550,6 +550,121 @@ describe('extractQueryAndVariables', () => {
     })
     expect(extractQueryAndVariables(entry)).toEqual({ query: '' })
   })
+
+  it('POST with extensions object → extensions pretty-printed', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+        postData: {
+          text: JSON.stringify({ query: '{ hero }', extensions: { tracing: true } }),
+        },
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: '{\n  "tracing": true\n}',
+    })
+  })
+
+  it('POST with null extensions → no extensions', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+        postData: { text: JSON.stringify({ query: '{ hero }', extensions: null }) },
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: undefined,
+    })
+  })
+
+  it('POST with non-object extensions (string) → no extensions', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+        postData: { text: JSON.stringify({ query: '{ hero }', extensions: 'not-an-object' }) },
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: undefined,
+    })
+  })
+
+  it('POST with no extensions field → no extensions', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+        postData: { text: JSON.stringify({ query: '{ hero }' }) },
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: undefined,
+    })
+  })
+
+  it('GET with valid extensions param → extensions pretty-printed', () => {
+    const query = encodeURIComponent('{ hero }')
+    const extensions = encodeURIComponent(JSON.stringify({ tracing: true }))
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: `https://example.com/graphql?query=${query}&extensions=${extensions}`,
+        headers: [],
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: '{\n  "tracing": true\n}',
+    })
+  })
+
+  it('GET with invalid extensions param → no extensions', () => {
+    const query = encodeURIComponent('{ hero }')
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: `https://example.com/graphql?query=${query}&extensions=not-json`,
+        headers: [],
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: undefined,
+    })
+  })
+
+  it('GET with no extensions param → no extensions', () => {
+    const query = encodeURIComponent('{ hero }')
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: `https://example.com/graphql?query=${query}`,
+        headers: [],
+      },
+    })
+    expect(extractQueryAndVariables(entry)).toEqual({
+      query: '{ hero }',
+      variables: undefined,
+      extensions: undefined,
+    })
+  })
 })
 
 describe('buildCurlCommand', () => {
diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts
index f74176c..73b179a 100644
--- a/entrypoints/devtools-panel/har.ts
+++ b/entrypoints/devtools-panel/har.ts
@@ -30,6 +30,7 @@ export type GraphQLRequest = {
   headers: Array<{ name: string; value: string }>
   query: string
   variables?: string
+  extensions?: string
   response?: string
   responseHeaders?: Array<{ name: string; value: string }>
 }
@@ -63,6 +64,7 @@ export function isGraphQLEntry(entry: HAREntry): boolean {
 export type QueryAndVariables = {
   query: string
   variables?: string
+  extensions?: string
 }
 
 export function extractQueryAndVariables(entry: HAREntry): QueryAndVariables {
@@ -76,7 +78,11 @@ export function extractQueryAndVariables(entry: HAREntry): QueryAndVariables {
           body.variables !== null && typeof body.variables === 'object'
             ? JSON.stringify(body.variables, null, 2)
             : undefined
-        return { query: body.query, variables }
+        const extensions =
+          body.extensions !== null && typeof body.extensions === 'object'
+            ? JSON.stringify(body.extensions, null, 2)
+            : undefined
+        return { query: body.query, variables, extensions }
       }
     } catch {
       // fall through
@@ -97,7 +103,16 @@ export function extractQueryAndVariables(entry: HAREntry): QueryAndVariables {
             // not valid JSON, skip
           }
         }
-        return { query, variables }
+        const extensionsParam = params.get('extensions')
+        let extensions: string | undefined
+        if (extensionsParam) {
+          try {
+            extensions = JSON.stringify(JSON.parse(extensionsParam), null, 2)
+          } catch {
+            // not valid JSON, skip
+          }
+        }
+        return { query, variables, extensions }
       }
     } catch {
       // fall through
diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts
index 83e41c8..3584ba7 100644
--- a/entrypoints/devtools-panel/useGraphQLRequests.ts
+++ b/entrypoints/devtools-panel/useGraphQLRequests.ts
@@ -16,7 +16,7 @@ export function useGraphQLRequests(autoClear: boolean): {
     async function handleRequest(entry: HAREntry) {
       if (!isGraphQLEntry(entry)) return
       const { operationName, operationType } = extractOperationInfo(entry)
-      const { query, variables } = extractQueryAndVariables(entry)
+      const { query, variables, extensions } = extractQueryAndVariables(entry)
       const responseText = await new Promise((resolve) => {
         entry.getContent((content, encoding) => {
           if (encoding === 'base64') {
@@ -44,6 +44,7 @@ export function useGraphQLRequests(autoClear: boolean): {
           headers: entry.request.headers,
           query,
           variables,
+          extensions,
           response: responseText || undefined,
           responseHeaders: entry.response.headers,
         },
diff --git a/package.json b/package.json
index 4409c5a..8f47387 100644
--- a/package.json
+++ b/package.json
@@ -34,10 +34,12 @@
     "@graphiql/plugin-explorer": "^5.1.1",
     "@graphiql/react": "^0.37.3",
     "@graphiql/toolkit": "^0.11.3",
+    "@microlink/react-json-view": "^1.27.1",
     "filesize": "^11.0.13",
     "graphiql": "^5.2.2",
     "graphql-ws": "^6.0.7",
     "graphql": "^16.13.0",
+    "highlight.js": "^11.11.1",
     "pretty-ms": "^9.3.0",
     "react": "^19.2.4",
     "react-dom": "^19.2.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aef42bd..d69e1a8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
       '@graphiql/toolkit':
         specifier: ^0.11.3
         version: 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)
+      '@microlink/react-json-view':
+        specifier: ^1.27.1
+        version: 1.27.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
       filesize:
         specifier: ^11.0.13
         version: 11.0.13
@@ -29,6 +32,9 @@ importers:
       graphql-ws:
         specifier: ^6.0.7
         version: 6.0.7(graphql@16.13.0)
+      highlight.js:
+        specifier: ^11.11.1
+        version: 11.11.1
       pretty-ms:
         specifier: ^9.3.0
         version: 9.3.0
@@ -684,6 +690,13 @@ packages:
   '@jridgewell/trace-mapping@0.3.31':
     resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
 
+  '@microlink/react-json-view@1.27.1':
+    resolution: {integrity: sha512-HUhKcaXB8QQvOWYNBXm8p6x6SPvXzmWjc0gP6GHN+kdpWOwniUdikJe9+GHB4/tuft92idaHXTxhDJZEMW966g==}
+    engines: {node: '>=17'}
+    peerDependencies:
+      react: '>= 15'
+      react-dom: '>= 15'
+
   '@n1ru4l/push-pull-async-iterable-iterator@3.2.0':
     resolution: {integrity: sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q==}
     engines: {node: '>=12'}
@@ -1538,6 +1551,9 @@ packages:
   '@types/babel__traverse@7.28.0':
     resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
 
+  '@types/base16@1.0.5':
+    resolution: {integrity: sha512-OzOWrTluG9cwqidEzC/Q6FAmIPcnZfm8BFRlIx0+UIUqnuAmi5OS88O0RpT3Yz6qdmqObvUhasrbNsCofE4W9A==}
+
   '@types/chai@5.2.3':
     resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
 
@@ -1559,6 +1575,9 @@ packages:
   '@types/har-format@1.2.16':
     resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==}
 
+  '@types/lodash@4.17.24':
+    resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
+
   '@types/minimatch@3.0.5':
     resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
 
@@ -1736,6 +1755,9 @@ packages:
     resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
     engines: {node: 18 || 20 || >=22}
 
+  base16@1.0.0:
+    resolution: {integrity: sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==}
+
   baseline-browser-mapping@2.10.0:
     resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==}
     engines: {node: '>=6.0.0'}
@@ -1854,13 +1876,25 @@ packages:
     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
     engines: {node: '>=6'}
 
+  color-convert@1.9.3:
+    resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
 
+  color-name@1.1.3:
+    resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
+
   color-name@1.1.4:
     resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
 
+  color-string@1.9.1:
+    resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
+  color@3.2.1:
+    resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
+
   commander@2.9.0:
     resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==}
     engines: {node: '>= 0.6.x'}
@@ -2271,6 +2305,10 @@ packages:
     resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
     engines: {node: '>=8'}
 
+  highlight.js@11.11.1:
+    resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+    engines: {node: '>=12.0.0'}
+
   hookable@6.0.1:
     resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==}
 
@@ -2326,6 +2364,9 @@ packages:
   is-arrayish@0.2.1:
     resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
 
+  is-arrayish@0.3.4:
+    resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
   is-docker@2.2.1:
     resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
     engines: {node: '>=8'}
@@ -2535,6 +2576,9 @@ packages:
     resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
     engines: {node: '>=14'}
 
+  lodash.curry@4.1.1:
+    resolution: {integrity: sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==}
+
   lodash.includes@4.3.0:
     resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
 
@@ -2889,6 +2933,9 @@ packages:
     resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
     hasBin: true
 
+  react-base16-styling@0.9.1:
+    resolution: {integrity: sha512-1s0CY1zRBOQ5M3T61wetEpvQmsYSNtWEcdYzyZNxKa8t7oDvaOn9d21xrGezGAHFWLM7SHcktPuPTrvoqxSfKw==}
+
   react-compiler-runtime@19.1.0-rc.1:
     resolution: {integrity: sha512-wCt6g+cRh8g32QT18/9blfQHywGjYu+4FlEc3CW1mx3pPxYzZZl1y+VtqxRgnKKBCFLIGUYxog4j4rs5YS86hw==}
     peerDependencies:
@@ -2902,6 +2949,9 @@ packages:
   react-is@17.0.2:
     resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
 
+  react-lifecycles-compat@3.0.4:
+    resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==}
+
   react-refresh@0.18.0:
     resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
     engines: {node: '>=0.10.0'}
@@ -2936,6 +2986,12 @@ packages:
       '@types/react':
         optional: true
 
+  react-textarea-autosize@8.5.9:
+    resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   react-window@2.2.7:
     resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==}
     peerDependencies:
@@ -3057,6 +3113,9 @@ packages:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
 
+  simple-swizzle@0.2.4:
+    resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
   sisteransi@1.0.5:
     resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
 
@@ -3283,6 +3342,33 @@ packages:
       '@types/react':
         optional: true
 
+  use-composed-ref@1.4.0:
+    resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  use-isomorphic-layout-effect@1.2.1:
+    resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
+  use-latest@1.3.0:
+    resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==}
+    peerDependencies:
+      '@types/react': '*'
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+
   use-sidecar@1.1.3:
     resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
     engines: {node: '>=10'}
@@ -4049,6 +4135,16 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.5
 
+  '@microlink/react-json-view@1.27.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      react: 19.2.4
+      react-base16-styling: 0.9.1
+      react-dom: 19.2.4(react@19.2.4)
+      react-lifecycles-compat: 3.0.4
+      react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4)
+    transitivePeerDependencies:
+      - '@types/react'
+
   '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': {}
 
   '@nodelib/fs.scandir@2.1.5':
@@ -4706,6 +4802,8 @@ snapshots:
     dependencies:
       '@babel/types': 7.29.0
 
+  '@types/base16@1.0.5': {}
+
   '@types/chai@5.2.3':
     dependencies:
       '@types/deep-eql': 4.0.2
@@ -4728,6 +4826,8 @@ snapshots:
 
   '@types/har-format@1.2.16': {}
 
+  '@types/lodash@4.17.24': {}
+
   '@types/minimatch@3.0.5': {}
 
   '@types/node@25.3.5':
@@ -4917,6 +5017,8 @@ snapshots:
 
   balanced-match@4.0.4: {}
 
+  base16@1.0.0: {}
+
   baseline-browser-mapping@2.10.0: {}
 
   bidi-js@1.0.3:
@@ -5038,12 +5140,28 @@ snapshots:
 
   clsx@2.1.1: {}
 
+  color-convert@1.9.3:
+    dependencies:
+      color-name: 1.1.3
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
 
+  color-name@1.1.3: {}
+
   color-name@1.1.4: {}
 
+  color-string@1.9.1:
+    dependencies:
+      color-name: 1.1.4
+      simple-swizzle: 0.2.4
+
+  color@3.2.1:
+    dependencies:
+      color-convert: 1.9.3
+      color-string: 1.9.1
+
   commander@2.9.0:
     dependencies:
       graceful-readlink: 1.0.1
@@ -5410,6 +5528,8 @@ snapshots:
 
   has-flag@4.0.0: {}
 
+  highlight.js@11.11.1: {}
+
   hookable@6.0.1: {}
 
   html-encoding-sniffer@6.0.0:
@@ -5463,6 +5583,8 @@ snapshots:
 
   is-arrayish@0.2.1: {}
 
+  is-arrayish@0.3.4: {}
+
   is-docker@2.2.1: {}
 
   is-docker@3.0.0: {}
@@ -5668,6 +5790,8 @@ snapshots:
       pkg-types: 2.3.0
       quansync: 0.2.11
 
+  lodash.curry@4.1.1: {}
+
   lodash.includes@4.3.0: {}
 
   lodash.isboolean@3.0.3: {}
@@ -6066,6 +6190,16 @@ snapshots:
       minimist: 1.2.8
       strip-json-comments: 2.0.1
 
+  react-base16-styling@0.9.1:
+    dependencies:
+      '@babel/runtime': 7.28.6
+      '@types/base16': 1.0.5
+      '@types/lodash': 4.17.24
+      base16: 1.0.0
+      color: 3.2.1
+      csstype: 3.2.3
+      lodash.curry: 4.1.1
+
   react-compiler-runtime@19.1.0-rc.1(react@19.2.4):
     dependencies:
       react: 19.2.4
@@ -6077,6 +6211,8 @@ snapshots:
 
   react-is@17.0.2: {}
 
+  react-lifecycles-compat@3.0.4: {}
+
   react-refresh@0.18.0: {}
 
   react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
@@ -6106,6 +6242,15 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.2.14
 
+  react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4):
+    dependencies:
+      '@babel/runtime': 7.28.6
+      react: 19.2.4
+      use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4)
+      use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4)
+    transitivePeerDependencies:
+      - '@types/react'
+
   react-window@2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
     dependencies:
       react: 19.2.4
@@ -6256,6 +6401,10 @@ snapshots:
 
   signal-exit@4.1.0: {}
 
+  simple-swizzle@0.2.4:
+    dependencies:
+      is-arrayish: 0.3.4
+
   sisteransi@1.0.5: {}
 
   slice-ansi@7.1.2:
@@ -6473,6 +6622,25 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.2.14
 
+  use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+    optionalDependencies:
+      '@types/react': 19.2.14
+
+  use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+    optionalDependencies:
+      '@types/react': 19.2.14
+
+  use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4):
+    dependencies:
+      react: 19.2.4
+      use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4)
+    optionalDependencies:
+      '@types/react': 19.2.14
+
   use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
     dependencies:
       detect-node-es: 1.1.0

From 5b6bc59a04e3cc1e9e0e0ea8c6bd70aef917db0b Mon Sep 17 00:00:00 2001
From: Jason Simeone 
Date: Tue, 3 Mar 2026 23:56:00 -0500
Subject: [PATCH 13/30] Populate the response tab

---
 entrypoints/devtools-panel/RequestModal.tsx   |   2 +
 entrypoints/devtools-panel/RequestTab.css     |  65 +------
 entrypoints/devtools-panel/ResponseTab.css    |   8 +
 entrypoints/devtools-panel/ResponseTab.tsx    | 107 ++++++++++++
 .../__tests__/RequestModal.test.tsx           |  18 ++
 .../__tests__/ResponseTab.test.tsx            | 158 ++++++++++++++++++
 entrypoints/devtools-panel/tab-shared.css     |  62 +++++++
 7 files changed, 357 insertions(+), 63 deletions(-)
 create mode 100644 entrypoints/devtools-panel/ResponseTab.css
 create mode 100644 entrypoints/devtools-panel/ResponseTab.tsx
 create mode 100644 entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
 create mode 100644 entrypoints/devtools-panel/tab-shared.css

diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx
index 212634b..5a77ae3 100644
--- a/entrypoints/devtools-panel/RequestModal.tsx
+++ b/entrypoints/devtools-panel/RequestModal.tsx
@@ -4,6 +4,7 @@ import './RequestModal.css'
 import type { GraphQLRequest } from './har'
 import { HeadersTable } from './HeadersTable'
 import { RequestTab } from './RequestTab'
+import { ResponseTab } from './ResponseTab'
 
 type Tab = 'headers' | 'request' | 'response'
 
@@ -61,6 +62,7 @@ export function RequestModal({ request, onClose }: Props) {
             
)} {activeTab === 'request' && } + {activeTab === 'response' && }
diff --git a/entrypoints/devtools-panel/RequestTab.css b/entrypoints/devtools-panel/RequestTab.css index 307d6a3..8067ad9 100644 --- a/entrypoints/devtools-panel/RequestTab.css +++ b/entrypoints/devtools-panel/RequestTab.css @@ -1,69 +1,8 @@ +@import './tab-shared.css'; + .gt-request-tab { padding: var(--px-8) var(--px-12); display: flex; flex-direction: column; gap: var(--px-16); } - -.gt-query-block { - margin: 0; - padding: var(--px-8) var(--px-12); - border-radius: var(--border-radius-4); - background: hsla(var(--color-neutral), var(--alpha-background-light)); - color: hsl(var(--color-neutral)); - font-family: var(--font-family); - font-size: var(--font-size-body); - overflow-x: auto; - white-space: pre; -} - -/* Raw query toggle button */ -.gt-raw-toggle { - background: none; - border: 1px solid transparent; - cursor: pointer; - padding: 1px var(--px-6); - border-radius: var(--border-radius-4); - font-family: var(--font-family); - font-size: var(--font-size-body); - color: hsl(var(--color-neutral)); - opacity: 0.6; - line-height: 1.4; -} - -.gt-raw-toggle:hover { - opacity: 1; - background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); -} - -.gt-raw-toggle--active { - opacity: 1; - border-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); - background-color: hsla(var(--color-neutral), var(--alpha-background-light)); -} - -/* Highlight.js token colors — all grounded in GraphiQL CSS variables */ -.gt-query-block .hljs-keyword { - color: hsl(var(--color-primary)); - font-weight: 600; -} - -.gt-query-block .hljs-variable { - color: hsl(var(--color-primary)); - opacity: 0.8; -} - -.gt-query-block .hljs-comment { - opacity: 0.5; - font-style: italic; -} - -/* react-json-view container — background comes from theme but we make it transparent; - wrap it in the same box style as the query block */ -.gt-json-block { - padding: var(--px-8) var(--px-12); - border-radius: var(--border-radius-4); - background: hsla(var(--color-neutral), var(--alpha-background-light)); - font-family: var(--font-family); - font-size: var(--font-size-body); -} diff --git a/entrypoints/devtools-panel/ResponseTab.css b/entrypoints/devtools-panel/ResponseTab.css new file mode 100644 index 0000000..e349a19 --- /dev/null +++ b/entrypoints/devtools-panel/ResponseTab.css @@ -0,0 +1,8 @@ +@import './tab-shared.css'; + +.gt-response-tab { + padding: var(--px-8) var(--px-12); + display: flex; + flex-direction: column; + gap: var(--px-16); +} diff --git a/entrypoints/devtools-panel/ResponseTab.tsx b/entrypoints/devtools-panel/ResponseTab.tsx new file mode 100644 index 0000000..6ce692f --- /dev/null +++ b/entrypoints/devtools-panel/ResponseTab.tsx @@ -0,0 +1,107 @@ +import ReactJsonView from '@microlink/react-json-view' +import { useMemo, useState } from 'react' + +import './ResponseTab.css' +import type { GraphQLRequest } from './har' + +function parseJsonObject(str: string): Record | null { + try { + const parsed = JSON.parse(str) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed + return null + } catch { + return null + } +} + +function copyText(text: string) { + navigator.clipboard.writeText(text).catch(() => {}) +} + +const CopyIcon = () => ( + +) + +type Props = { + request: GraphQLRequest +} + +export function ResponseTab({ request }: Props) { + const isDark = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)').matches, []) + const [showRaw, setShowRaw] = useState(false) + + const parsedResponse = useMemo( + () => (request.response ? parseJsonObject(request.response) : null), + [request.response] + ) + + const prettifiedResponse = useMemo( + () => (parsedResponse ? JSON.stringify(parsedResponse, null, 2) : null), + [parsedResponse] + ) + + const jsonTheme = isDark ? 'monokai' : 'rjv-default' + const jsonViewStyle = { background: 'transparent', padding: '0' } + + if (!request.response) { + return ( +
+

No response body

+
+ ) + } + + return ( +
+
+

+ Body + + +

+ {showRaw || !parsedResponse ? ( +
+            {request.response}
+          
+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx index 4552c37..96d8b75 100644 --- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -7,6 +7,9 @@ vi.mock('../RequestModal.css', () => ({})) vi.mock('../RequestTab', () => ({ RequestTab: () =>
, })) +vi.mock('../ResponseTab', () => ({ + ResponseTab: () =>
, +})) import type { GraphQLRequest } from '../har' import { RequestModal } from '../RequestModal' @@ -140,4 +143,19 @@ describe('RequestModal', () => { renderModal() expect(screen.queryByTestId('request-tab-mock')).not.toBeInTheDocument() }) + + // --------------------------------------------------------------------------- + // Response tab content + // --------------------------------------------------------------------------- + + it('Response tab renders ResponseTab component when active', () => { + renderModal() + fireEvent.click(screen.getByRole('tab', { name: 'Response' })) + expect(screen.getByTestId('response-tab-mock')).toBeInTheDocument() + }) + + it('ResponseTab is not in DOM when a different tab is active', () => { + renderModal() + expect(screen.queryByTestId('response-tab-mock')).not.toBeInTheDocument() + }) }) diff --git a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx new file mode 100644 index 0000000..b83b710 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx @@ -0,0 +1,158 @@ +import { cleanup, render, screen, fireEvent } from '@testing-library/react' +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' + +vi.mock('../ResponseTab.css', () => ({})) +vi.mock('@microlink/react-json-view', () => ({ + default: ({ src }: { src: object }) => ( +
+ ), +})) + +import type { GraphQLRequest } from '../har' +import { ResponseTab } from '../ResponseTab' + +function makeRequest(overrides: Partial = {}): GraphQLRequest { + return { + id: '1', + operationName: 'GetHero', + operationType: 'query', + status: 200, + size: 512, + time: 123, + url: 'https://api.example.com/graphql', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + query: 'query GetHero { hero { name } }', + ...overrides, + } +} + +describe('ResponseTab', () => { + beforeEach(() => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false })) + ) + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + // --------------------------------------------------------------------------- + // Empty state + // --------------------------------------------------------------------------- + + it('shows empty state when response is undefined', () => { + render() + expect(screen.getByText('No response body')).toBeInTheDocument() + }) + + it('does not render the Body heading when response is undefined', () => { + render() + expect(screen.queryByText('Body')).not.toBeInTheDocument() + }) + + // --------------------------------------------------------------------------- + // Body section + // --------------------------------------------------------------------------- + + it('renders "Body" heading when response is present', () => { + render() + expect(screen.getByText('Body')).toBeInTheDocument() + }) + + it('renders ReactJsonView with parsed response when response is valid JSON object', () => { + const response = '{"data":{"hero":{"name":"Luke"}}}' + render() + const jsonView = screen.getByTestId('json-view') + expect(jsonView).toBeInTheDocument() + expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ + data: { hero: { name: 'Luke' } }, + }) + }) + + it('falls back to
 display when response is not valid JSON', () => {
+    render()
+    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+    const pres = document.querySelectorAll('pre')
+    const fallback = Array.from(pres).find((p) => p.textContent === 'not-json')
+    expect(fallback).toBeDefined()
+  })
+
+  // ---------------------------------------------------------------------------
+  // Copy button
+  // ---------------------------------------------------------------------------
+
+  it('copy button is present when response is present', () => {
+    render()
+    expect(screen.getByTitle('Copy response')).toBeInTheDocument()
+  })
+
+  it('clicking copy response copies prettified JSON when Raw is inactive and response is valid JSON', () => {
+    const response = '{"data":{}}'
+    render()
+    fireEvent.click(screen.getByTitle('Copy response'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+      JSON.stringify({ data: {} }, null, 2)
+    )
+  })
+
+  it('clicking copy response copies raw string when Raw is active', () => {
+    const response = '{"data":{}}'
+    render()
+    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+    fireEvent.click(screen.getByTitle('Copy response'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
+  })
+
+  it('clicking copy response copies raw string when response is not valid JSON', () => {
+    const response = 'not-json'
+    render()
+    fireEvent.click(screen.getByTitle('Copy response'))
+    expect(navigator.clipboard.writeText).toHaveBeenCalledWith(response)
+  })
+
+  // ---------------------------------------------------------------------------
+  // Raw toggle
+  // ---------------------------------------------------------------------------
+
+  it('Raw toggle button is present when response is present', () => {
+    render()
+    expect(screen.getByRole('button', { name: 'Raw' })).toBeInTheDocument()
+  })
+
+  it('Raw toggle is inactive by default', () => {
+    render()
+    expect(screen.getByRole('button', { name: 'Raw' })).not.toHaveClass('gt-raw-toggle--active')
+  })
+
+  it('clicking Raw toggle shows raw response in 
', () => {
+    const response = '{"data":{}}'
+    render()
+    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+    expect(screen.queryByTestId('json-view')).not.toBeInTheDocument()
+    const pre = document.querySelector('pre')
+    expect(pre?.textContent).toBe(response)
+  })
+
+  it('clicking Raw toggle marks the button as active', () => {
+    render()
+    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+    expect(screen.getByRole('button', { name: 'Raw' })).toHaveClass('gt-raw-toggle--active')
+  })
+
+  it('clicking Raw toggle again reverts to ReactJsonView', () => {
+    const response = '{"data":{}}'
+    render()
+    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+    fireEvent.click(screen.getByRole('button', { name: 'Raw' }))
+    expect(screen.getByTestId('json-view')).toBeInTheDocument()
+  })
+})
diff --git a/entrypoints/devtools-panel/tab-shared.css b/entrypoints/devtools-panel/tab-shared.css
new file mode 100644
index 0000000..9ddc2e9
--- /dev/null
+++ b/entrypoints/devtools-panel/tab-shared.css
@@ -0,0 +1,62 @@
+.gt-query-block {
+  margin: 0;
+  padding: var(--px-8) var(--px-12);
+  border-radius: var(--border-radius-4);
+  background: hsla(var(--color-neutral), var(--alpha-background-light));
+  color: hsl(var(--color-neutral));
+  font-family: var(--font-family);
+  font-size: var(--font-size-body);
+  overflow-x: auto;
+  white-space: pre;
+}
+
+/* Raw toggle button */
+.gt-raw-toggle {
+  background: none;
+  border: 1px solid transparent;
+  cursor: pointer;
+  padding: 1px var(--px-6);
+  border-radius: var(--border-radius-4);
+  font-family: var(--font-family);
+  font-size: var(--font-size-body);
+  color: hsl(var(--color-neutral));
+  opacity: 0.6;
+  line-height: 1.4;
+}
+
+.gt-raw-toggle:hover {
+  opacity: 1;
+  background-color: hsla(var(--color-neutral), var(--alpha-background-medium));
+}
+
+.gt-raw-toggle--active {
+  opacity: 1;
+  border-color: hsla(var(--color-neutral), var(--alpha-background-heavy));
+  background-color: hsla(var(--color-neutral), var(--alpha-background-light));
+}
+
+/* Highlight.js token colors — all grounded in GraphiQL CSS variables */
+.gt-query-block .hljs-keyword {
+  color: hsl(var(--color-primary));
+  font-weight: 600;
+}
+
+.gt-query-block .hljs-variable {
+  color: hsl(var(--color-primary));
+  opacity: 0.8;
+}
+
+.gt-query-block .hljs-comment {
+  opacity: 0.5;
+  font-style: italic;
+}
+
+/* react-json-view container — background comes from theme but we make it transparent;
+   wrap it in the same box style as the query block */
+.gt-json-block {
+  padding: var(--px-8) var(--px-12);
+  border-radius: var(--border-radius-4);
+  background: hsla(var(--color-neutral), var(--alpha-background-light));
+  font-family: var(--font-family);
+  font-size: var(--font-size-body);
+}

From fdb995234c654c3edfd0ddee33d91058c3975f1a Mon Sep 17 00:00:00 2001
From: Jason Simeone 
Date: Wed, 4 Mar 2026 00:10:25 -0500
Subject: [PATCH 14/30] UI tweaks and fixes

---
 entrypoints/devtools-panel/CopyButton.tsx     | 66 ++++++++++++++
 entrypoints/devtools-panel/HeadersTable.tsx   | 38 ++-------
 entrypoints/devtools-panel/RequestTab.tsx     | 47 ++--------
 entrypoints/devtools-panel/ResponseTab.tsx    | 34 ++------
 .../__tests__/CopyButton.test.tsx             | 85 +++++++++++++++++++
 .../__tests__/RequestTab.test.tsx             |  4 +-
 .../__tests__/ResponseTab.test.tsx            |  4 +-
 entrypoints/devtools-panel/tab-shared.css     |  4 +-
 8 files changed, 174 insertions(+), 108 deletions(-)
 create mode 100644 entrypoints/devtools-panel/CopyButton.tsx
 create mode 100644 entrypoints/devtools-panel/__tests__/CopyButton.test.tsx

diff --git a/entrypoints/devtools-panel/CopyButton.tsx b/entrypoints/devtools-panel/CopyButton.tsx
new file mode 100644
index 0000000..b828339
--- /dev/null
+++ b/entrypoints/devtools-panel/CopyButton.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useRef, useState } from 'react'
+
+const CopyIcon = () => (
+  
+)
+
+const CheckIcon = () => (
+  
+)
+
+type Props = {
+  text: string
+  title: string
+  className?: string
+}
+
+export function CopyButton({ text, title, className = 'gt-headers-copy-btn' }: Props) {
+  const [copied, setCopied] = useState(false)
+  const timeoutRef = useRef | null>(null)
+
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
+    }
+  }, [])
+
+  function handleClick() {
+    navigator.clipboard.writeText(text).catch(() => {})
+    if (timeoutRef.current !== null) clearTimeout(timeoutRef.current)
+    setCopied(true)
+    timeoutRef.current = setTimeout(() => setCopied(false), 1500)
+  }
+
+  return (
+    
+  )
+}
diff --git a/entrypoints/devtools-panel/HeadersTable.tsx b/entrypoints/devtools-panel/HeadersTable.tsx
index bc25017..9a8931b 100644
--- a/entrypoints/devtools-panel/HeadersTable.tsx
+++ b/entrypoints/devtools-panel/HeadersTable.tsx
@@ -1,23 +1,4 @@
-function copyText(text: string) {
-  navigator.clipboard.writeText(text).catch(() => {})
-}
-
-const CopyIcon = () => (
-  
-)
+import { CopyButton } from './CopyButton'
 
 type Props = {
   title: string
@@ -31,13 +12,10 @@ export function HeadersTable({ title, headers }: Props) {
       

{title} {visible && visible.length > 0 && ( - + /> )}

{visible && visible.length > 0 ? ( @@ -55,13 +33,7 @@ export function HeadersTable({ title, headers }: Props) {
{name} {value} - +
{name} {value} diff --git a/entrypoints/devtools-panel/RequestTab.tsx b/entrypoints/devtools-panel/RequestTab.tsx index 2176250..6d86e31 100644 --- a/entrypoints/devtools-panel/RequestTab.tsx +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -6,7 +6,9 @@ import { useMemo, useState } from 'react' import './RequestTab.css' import { CopyButton } from './CopyButton' +import { parseJsonObject } from './har' import type { GraphQLRequest } from './har' +import { useDarkMode } from './useDarkMode' hljs.registerLanguage('graphql', graphql) @@ -18,22 +20,12 @@ function formatQuery(raw: string): string { } } -function parseJsonObject(str: string): Record | null { - try { - const parsed = JSON.parse(str) - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed - return null - } catch { - return null - } -} - type Props = { request: GraphQLRequest } export function RequestTab({ request }: Props) { - const isDark = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)').matches, []) + const isDark = useDarkMode() const [showRaw, setShowRaw] = useState(false) const formattedQuery = useMemo(() => formatQuery(request.query), [request.query]) diff --git a/entrypoints/devtools-panel/ResponseTab.tsx b/entrypoints/devtools-panel/ResponseTab.tsx index 46b2a80..f98fb8f 100644 --- a/entrypoints/devtools-panel/ResponseTab.tsx +++ b/entrypoints/devtools-panel/ResponseTab.tsx @@ -3,24 +3,16 @@ import { useMemo, useState } from 'react' import './ResponseTab.css' import { CopyButton } from './CopyButton' +import { parseJsonObject } from './har' import type { GraphQLRequest } from './har' - -function parseJsonObject(str: string): Record | null { - try { - const parsed = JSON.parse(str) - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed - return null - } catch { - return null - } -} +import { useDarkMode } from './useDarkMode' type Props = { request: GraphQLRequest } export function ResponseTab({ request }: Props) { - const isDark = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)').matches, []) + const isDark = useDarkMode() const [showRaw, setShowRaw] = useState(false) const parsedResponse = useMemo( diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx index c174cf0..6bba451 100644 --- a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx @@ -33,7 +33,7 @@ describe('RequestTab', () => { beforeEach(() => { vi.stubGlobal( 'matchMedia', - vi.fn(() => ({ matches: false })) + vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) ) Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, @@ -151,7 +151,7 @@ describe('RequestTab', () => { it('uses monokai theme in dark mode', () => { vi.stubGlobal( 'matchMedia', - vi.fn(() => ({ matches: true })) + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) ) render() expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') diff --git a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx index e98ab39..7f16963 100644 --- a/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx +++ b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx @@ -33,7 +33,7 @@ describe('ResponseTab', () => { beforeEach(() => { vi.stubGlobal( 'matchMedia', - vi.fn(() => ({ matches: false })) + vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) ) Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, @@ -78,7 +78,7 @@ describe('ResponseTab', () => { it('uses monokai theme in dark mode', () => { vi.stubGlobal( 'matchMedia', - vi.fn(() => ({ matches: true })) + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) ) render() expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') diff --git a/entrypoints/devtools-panel/__tests__/useDarkMode.test.ts b/entrypoints/devtools-panel/__tests__/useDarkMode.test.ts new file mode 100644 index 0000000..d74dafd --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/useDarkMode.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, vi, afterEach } from 'vitest' + +import { useDarkMode } from '../useDarkMode' + +function makeMatchMedia(matches: boolean) { + const listeners: Array<(e: { matches: boolean }) => void> = [] + return { + mql: { + matches, + addEventListener: vi.fn((_: string, fn: (e: { matches: boolean }) => void) => { + listeners.push(fn) + }), + removeEventListener: vi.fn((_: string, fn: (e: { matches: boolean }) => void) => { + const i = listeners.indexOf(fn) + if (i !== -1) listeners.splice(i, 1) + }), + }, + fire(newMatches: boolean) { + listeners.forEach((fn) => fn({ matches: newMatches })) + }, + } +} + +describe('useDarkMode', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns false when prefers-color-scheme is light', () => { + const { mql } = makeMatchMedia(false) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql) + ) + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(false) + }) + + it('returns true when prefers-color-scheme is dark', () => { + const { mql } = makeMatchMedia(true) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql) + ) + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(true) + }) + + it('updates when the media query fires a change event', () => { + const { mql, fire } = makeMatchMedia(false) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql) + ) + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(false) + + act(() => fire(true)) + expect(result.current).toBe(true) + + act(() => fire(false)) + expect(result.current).toBe(false) + }) + + it('removes the event listener on unmount', () => { + const { mql } = makeMatchMedia(false) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql) + ) + const { unmount } = renderHook(() => useDarkMode()) + expect(mql.addEventListener).toHaveBeenCalledTimes(1) + unmount() + expect(mql.removeEventListener).toHaveBeenCalledTimes(1) + expect(mql.removeEventListener.mock.calls[0][0]).toBe('change') + }) +}) diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index 73b179a..5f8c0e0 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -215,3 +215,13 @@ export function buildCurlCommand(request: GraphQLRequest): string { return parts.join(' ') } + +export function parseJsonObject(str: string): Record | null { + try { + const parsed = JSON.parse(str) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) return parsed + return null + } catch { + return null + } +} diff --git a/entrypoints/devtools-panel/useDarkMode.ts b/entrypoints/devtools-panel/useDarkMode.ts new file mode 100644 index 0000000..ce0135b --- /dev/null +++ b/entrypoints/devtools-panel/useDarkMode.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react' + +export function useDarkMode(): boolean { + const [isDark, setIsDark] = useState( + () => window.matchMedia('(prefers-color-scheme: dark)').matches + ) + + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + function handler(e: MediaQueryListEvent) { + setIsDark(e.matches) + } + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + return isDark +} From 886ae64d3dea6fc859c4a9a9f5ab19abde145406 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 00:47:45 -0500 Subject: [PATCH 18/30] Added support for extensions to the curl command generator --- .../devtools-panel/__tests__/har.test.ts | 21 +++++++++++++++++++ entrypoints/devtools-panel/har.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts index 6dd9c2f..419a5ed 100644 --- a/entrypoints/devtools-panel/__tests__/har.test.ts +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -706,6 +706,27 @@ describe('buildCurlCommand', () => { expect(cmd).not.toContain('variables') }) + it('POST with extensions → --data-raw body includes parsed extensions object', () => { + const cmd = buildCurlCommand(makeRequest({ extensions: '{"persistedQuery":{"version":1}}' })) + expect(cmd).toContain( + '--data-raw \'{"query":"query GetHero { hero { name } }","extensions":{"persistedQuery":{"version":1}}}\'' + ) + }) + + it('POST with non-JSON extensions → extensions key omitted from body', () => { + const cmd = buildCurlCommand(makeRequest({ extensions: 'not json' })) + expect(cmd).toContain('--data-raw \'{"query":"query GetHero { hero { name } }"}\'') + expect(cmd).not.toContain('extensions') + }) + + it('POST with variables and extensions → body includes both', () => { + const cmd = buildCurlCommand( + makeRequest({ variables: '{"id":"1"}', extensions: '{"persistedQuery":{"version":1}}' }) + ) + expect(cmd).toContain('"variables":{"id":"1"}') + expect(cmd).toContain('"extensions":{"persistedQuery":{"version":1}}') + }) + it('GET request → no --data-raw, URL used as-is', () => { const cmd = buildCurlCommand( makeRequest({ diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index 5f8c0e0..c4e54f0 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -210,6 +210,13 @@ export function buildCurlCommand(request: GraphQLRequest): string { // variables couldn't be parsed; omit from body } } + if (request.extensions) { + try { + body.extensions = JSON.parse(request.extensions) + } catch { + // extensions couldn't be parsed; omit from body + } + } parts.push(`--data-raw ${shellEscape(JSON.stringify(body))}`) } From beec3aef01cfc2c4a3d4be38878a6ea1d9c88e74 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 00:59:21 -0500 Subject: [PATCH 19/30] Use the wxt browser instead of chrome --- .../__tests__/useGraphQLRequests.test.ts | 136 +++++++++--------- .../devtools-panel/useGraphQLRequests.ts | 9 +- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index 9a3f936..898b26a 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -1,6 +1,6 @@ import { renderHook, act } from '@testing-library/react' // @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import type { HAREntry } from '../har' import { useGraphQLRequests } from '../useGraphQLRequests' @@ -8,24 +8,21 @@ import { useGraphQLRequests } from '../useGraphQLRequests' type RequestListener = (entry: HAREntry) => void type NavigatedListener = () => void -function makeChromeMock() { - let capturedRequestListener: RequestListener | null = null - const onRequestFinishedAddListener = vi.fn((fn: RequestListener) => { - capturedRequestListener = fn - }) - const onRequestFinishedRemoveListener = vi.fn((fn: RequestListener) => { - if (capturedRequestListener === fn) capturedRequestListener = null - }) - - let capturedNavigatedListeners: NavigatedListener[] = [] - const onNavigatedAddListener = vi.fn((fn: NavigatedListener) => { - capturedNavigatedListeners.push(fn) - }) - const onNavigatedRemoveListener = vi.fn((fn: NavigatedListener) => { - capturedNavigatedListeners = capturedNavigatedListeners.filter((l) => l !== fn) - }) +// vi.hoisted runs before vi.mock factories, ensuring these are defined first +const { + onRequestFinishedAddListener, + onRequestFinishedRemoveListener, + onNavigatedAddListener, + onNavigatedRemoveListener, +} = vi.hoisted(() => ({ + onRequestFinishedAddListener: vi.fn<(fn: RequestListener) => void>(), + onRequestFinishedRemoveListener: vi.fn<(fn: RequestListener) => void>(), + onNavigatedAddListener: vi.fn<(fn: NavigatedListener) => void>(), + onNavigatedRemoveListener: vi.fn<(fn: NavigatedListener) => void>(), +})) - const chrome = { +vi.mock('wxt/browser', () => ({ + browser: { devtools: { network: { onRequestFinished: { @@ -38,25 +35,19 @@ function makeChromeMock() { }, }, }, - } + }, +})) - function fire(entry: HAREntry) { - capturedRequestListener?.(entry) - } +// Capture state reset per-test in beforeEach +let capturedRequestListener: RequestListener | null = null +let capturedNavigatedListeners: NavigatedListener[] = [] - function fireNavigated() { - capturedNavigatedListeners.forEach((l) => l()) - } +function fire(entry: HAREntry) { + capturedRequestListener?.(entry) +} - return { - chrome, - addListener: onRequestFinishedAddListener, - removeListener: onRequestFinishedRemoveListener, - onNavigatedAddListener, - onNavigatedRemoveListener, - fire, - fireNavigated, - } +function fireNavigated() { + capturedNavigatedListeners.forEach((l) => l()) } function makeGraphQLEntry( @@ -93,15 +84,22 @@ function makeNonGraphQLEntry(): HAREntry { } describe('useGraphQLRequests', () => { - let mock: ReturnType - beforeEach(() => { - mock = makeChromeMock() - vi.stubGlobal('chrome', mock.chrome) - }) - - afterEach(() => { - vi.unstubAllGlobals() + capturedRequestListener = null + capturedNavigatedListeners = [] + vi.clearAllMocks() + onRequestFinishedAddListener.mockImplementation((fn: RequestListener) => { + capturedRequestListener = fn + }) + onRequestFinishedRemoveListener.mockImplementation((fn: RequestListener) => { + if (capturedRequestListener === fn) capturedRequestListener = null + }) + onNavigatedAddListener.mockImplementation((fn: NavigatedListener) => { + capturedNavigatedListeners.push(fn) + }) + onNavigatedRemoveListener.mockImplementation((fn: NavigatedListener) => { + capturedNavigatedListeners = capturedNavigatedListeners.filter((l) => l !== fn) + }) }) it('returns empty array initially', () => { @@ -111,20 +109,20 @@ describe('useGraphQLRequests', () => { it('registers listener on mount', () => { renderHook(() => useGraphQLRequests(false)) - expect(mock.addListener).toHaveBeenCalledOnce() + expect(onRequestFinishedAddListener).toHaveBeenCalledOnce() }) it('removes same listener on unmount', () => { const { unmount } = renderHook(() => useGraphQLRequests(false)) - const registeredFn = mock.addListener.mock.calls[0][0] + const registeredFn = onRequestFinishedAddListener.mock.calls[0][0] unmount() - expect(mock.removeListener).toHaveBeenCalledWith(registeredFn) + expect(onRequestFinishedRemoveListener).toHaveBeenCalledWith(registeredFn) }) it('adds a GraphQLRequest entry when a matching HAR entry fires', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) expect(result.current.requests[0]).toMatchObject({ @@ -145,7 +143,7 @@ describe('useGraphQLRequests', () => { it('stores variables when present in the request', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire( + fire( makeGraphQLEntry({ postData: { text: JSON.stringify({ @@ -165,7 +163,7 @@ describe('useGraphQLRequests', () => { it('response is undefined when getContent returns empty string', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry({}, '')) + fire(makeGraphQLEntry({}, '')) }) expect(result.current.requests[0].response).toBeUndefined() }) @@ -173,7 +171,7 @@ describe('useGraphQLRequests', () => { it('ignores non-GraphQL entries', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeNonGraphQLEntry()) + fire(makeNonGraphQLEntry()) }) expect(result.current.requests).toHaveLength(0) }) @@ -181,8 +179,8 @@ describe('useGraphQLRequests', () => { it('multiple entries accumulate in order', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) - mock.fire( + fire(makeGraphQLEntry()) + fire( makeGraphQLEntry({ postData: { text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }), @@ -204,9 +202,9 @@ describe('useGraphQLRequests', () => { it('id increments monotonically across multiple entries', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) - mock.fire(makeGraphQLEntry()) - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests.map((r) => r.id)).toEqual(['1', '2', '3']) }) @@ -214,8 +212,8 @@ describe('useGraphQLRequests', () => { it('clear() empties the request list', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(2) act(() => { @@ -227,11 +225,11 @@ describe('useGraphQLRequests', () => { it('navigation event clears requests when autoClear is true', async () => { const { result } = renderHook(() => useGraphQLRequests(true)) await act(async () => { - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) act(() => { - mock.fireNavigated() + fireNavigated() }) expect(result.current.requests).toHaveLength(0) }) @@ -239,28 +237,28 @@ describe('useGraphQLRequests', () => { it('navigation event does NOT clear when autoClear is false', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests).toHaveLength(1) act(() => { - mock.fireNavigated() + fireNavigated() }) expect(result.current.requests).toHaveLength(1) }) it('onNavigated listener is removed on unmount when autoClear is true', () => { const { unmount } = renderHook(() => useGraphQLRequests(true)) - expect(mock.onNavigatedAddListener).toHaveBeenCalledOnce() - const registeredFn = mock.onNavigatedAddListener.mock.calls[0][0] + expect(onNavigatedAddListener).toHaveBeenCalledOnce() + const registeredFn = onNavigatedAddListener.mock.calls[0][0] unmount() - expect(mock.onNavigatedRemoveListener).toHaveBeenCalledWith(registeredFn) + expect(onNavigatedRemoveListener).toHaveBeenCalledWith(registeredFn) }) it('decodes base64-encoded response content', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) const json = '{"data":{"hero":{"name":"Luke"}}}' await act(async () => { - mock.fire(makeGraphQLEntry({}, btoa(json), 'base64')) + fire(makeGraphQLEntry({}, btoa(json), 'base64')) }) expect(result.current.requests[0].response).toBe(json) }) @@ -272,7 +270,7 @@ describe('useGraphQLRequests', () => { { name: 'x-request-id', value: 'abc123' }, ] await act(async () => { - mock.fire(makeGraphQLEntry({}, undefined, '', headers)) + fire(makeGraphQLEntry({}, undefined, '', headers)) }) expect(result.current.requests[0].responseHeaders).toEqual(headers) }) @@ -280,7 +278,7 @@ describe('useGraphQLRequests', () => { it('responseHeaders is undefined when not present in the HAR entry', async () => { const { result } = renderHook(() => useGraphQLRequests(false)) await act(async () => { - mock.fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) }) expect(result.current.requests[0].responseHeaders).toBeUndefined() }) @@ -289,7 +287,7 @@ describe('useGraphQLRequests', () => { const { result } = renderHook(() => useGraphQLRequests(false)) const invalid = '!!!not-valid-base64!!!' await act(async () => { - mock.fire(makeGraphQLEntry({}, invalid, 'base64')) + fire(makeGraphQLEntry({}, invalid, 'base64')) }) expect(result.current.requests[0].response).toBe(invalid) }) @@ -298,8 +296,8 @@ describe('useGraphQLRequests', () => { const { rerender } = renderHook(({ autoClear }) => useGraphQLRequests(autoClear), { initialProps: { autoClear: false }, }) - expect(mock.onNavigatedAddListener).not.toHaveBeenCalled() + expect(onNavigatedAddListener).not.toHaveBeenCalled() rerender({ autoClear: true }) - expect(mock.onNavigatedAddListener).toHaveBeenCalledOnce() + expect(onNavigatedAddListener).toHaveBeenCalledOnce() }) }) diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index 3584ba7..79090c6 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react' +import { browser } from 'wxt/browser' import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables } from './har' import type { HAREntry, GraphQLRequest } from './har' @@ -50,9 +51,9 @@ export function useGraphQLRequests(autoClear: boolean): { }, ]) } - chrome.devtools.network.onRequestFinished.addListener(handleRequest) + browser.devtools.network.onRequestFinished.addListener(handleRequest) return () => { - chrome.devtools.network.onRequestFinished.removeListener(handleRequest) + browser.devtools.network.onRequestFinished.removeListener(handleRequest) } }, []) @@ -61,8 +62,8 @@ export function useGraphQLRequests(autoClear: boolean): { function handleNavigated() { setRequests([]) } - chrome.devtools.network.onNavigated.addListener(handleNavigated) - return () => chrome.devtools.network.onNavigated.removeListener(handleNavigated) + browser.devtools.network.onNavigated.addListener(handleNavigated) + return () => browser.devtools.network.onNavigated.removeListener(handleNavigated) }, [autoClear]) return { requests, clear } From d4ee038a19fd7046d12402e853fea3d460375776 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 01:06:30 -0500 Subject: [PATCH 20/30] One component per file --- CLAUDE.md | 1 + entrypoints/devtools-panel/CheckIcon.tsx | 16 ++++++++++ entrypoints/devtools-panel/CopyButton.tsx | 36 ++--------------------- entrypoints/devtools-panel/CopyIcon.tsx | 17 +++++++++++ 4 files changed, 36 insertions(+), 34 deletions(-) create mode 100644 entrypoints/devtools-panel/CheckIcon.tsx create mode 100644 entrypoints/devtools-panel/CopyIcon.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 8e59b69..ce9ac1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,3 +91,4 @@ Shared reusable components are in `styles/shared.css` with a `.gt-*` class prefi - No code change is considered complete unless tests have been added or updated to address the changes, the full test suite passes, the linter reports no errors, and the code is properly formatted. - Styling for UI components should mimic that of GraphiQL's UI design whenever possible, using the CSS variables and shared classes described above. +- All React components should be in their own module. diff --git a/entrypoints/devtools-panel/CheckIcon.tsx b/entrypoints/devtools-panel/CheckIcon.tsx new file mode 100644 index 0000000..2aaa276 --- /dev/null +++ b/entrypoints/devtools-panel/CheckIcon.tsx @@ -0,0 +1,16 @@ +export const CheckIcon = () => ( + +) diff --git a/entrypoints/devtools-panel/CopyButton.tsx b/entrypoints/devtools-panel/CopyButton.tsx index b828339..feb5909 100644 --- a/entrypoints/devtools-panel/CopyButton.tsx +++ b/entrypoints/devtools-panel/CopyButton.tsx @@ -1,39 +1,7 @@ import { useEffect, useRef, useState } from 'react' -const CopyIcon = () => ( - -) - -const CheckIcon = () => ( - -) +import { CheckIcon } from './CheckIcon' +import { CopyIcon } from './CopyIcon' type Props = { text: string diff --git a/entrypoints/devtools-panel/CopyIcon.tsx b/entrypoints/devtools-panel/CopyIcon.tsx new file mode 100644 index 0000000..d6ef94e --- /dev/null +++ b/entrypoints/devtools-panel/CopyIcon.tsx @@ -0,0 +1,17 @@ +export const CopyIcon = () => ( + +) From 406924b0678e9ac145cbb2f2673ba098713da153 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 11:38:25 -0500 Subject: [PATCH 21/30] Fix operation type icons --- entrypoints/devtools-panel/App.css | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index 4143d66..caef956 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -107,7 +107,7 @@ body { } .gt-op-badge { - display: inline-flex; + display: flex; align-items: center; justify-content: center; width: 1.25em; @@ -115,23 +115,21 @@ body { border-radius: var(--border-radius-4); font-size: 0.7em; font-weight: 700; - line-height: 1; margin-right: var(--px-6); flex-shrink: 0; color: white; - vertical-align: middle; } .gt-op-badge--query { - background-color: hsl(var(--color-primary)); + background-color: #4b9eed; } .gt-op-badge--mutation { - background-color: hsl(var(--color-error)); + background-color: hsl(var(--color-primary)); } .gt-op-badge--subscription { - background-color: hsl(var(--color-primary)); + background-color: #4b9eed; opacity: 0.65; } From 4a87923b84aeb753e3c7f252085d1713df224f8e Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 11:42:25 -0500 Subject: [PATCH 22/30] Don't show empty response sections --- entrypoints/devtools-panel/RequestTab.tsx | 4 ++-- .../devtools-panel/__tests__/RequestTab.test.tsx | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/entrypoints/devtools-panel/RequestTab.tsx b/entrypoints/devtools-panel/RequestTab.tsx index 6d86e31..728eedd 100644 --- a/entrypoints/devtools-panel/RequestTab.tsx +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -72,7 +72,7 @@ export function RequestTab({ request }: Props) { - {request.variables && ( + {request.variables && !(parsedVariables && Object.keys(parsedVariables).length === 0) && (

Variables @@ -97,7 +97,7 @@ export function RequestTab({ request }: Props) {

)} - {request.extensions && ( + {request.extensions && !(parsedExtensions && Object.keys(parsedExtensions).length === 0) && (

Extensions diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx index 6bba451..3daf733 100644 --- a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx @@ -141,6 +141,11 @@ describe('RequestTab', () => { expect(screen.getByText('Variables')).toBeInTheDocument() }) + it('Variables section is absent when variables is an empty object', () => { + render() + expect(screen.queryByText('Variables')).not.toBeInTheDocument() + }) + it('renders ReactJsonView with parsed variables when variables is valid JSON object', () => { render() const jsonView = screen.getByTestId('json-view') @@ -198,6 +203,11 @@ describe('RequestTab', () => { expect(screen.getByText('Extensions')).toBeInTheDocument() }) + it('Extensions section is absent when extensions is an empty object', () => { + render() + expect(screen.queryByText('Extensions')).not.toBeInTheDocument() + }) + it('renders ReactJsonView with parsed extensions when extensions is valid JSON object', () => { render() const jsonView = screen.getByTestId('json-view') From e25b136cbdf7ae8695dfe9282ce48f1e8a06e580 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 12:00:01 -0500 Subject: [PATCH 23/30] Added ability to see full request body --- entrypoints/devtools-panel/RequestTab.css | 4 + entrypoints/devtools-panel/RequestTab.tsx | 150 +++++++++++------- .../__tests__/RequestTab.test.tsx | 79 +++++++++ entrypoints/devtools-panel/har.ts | 1 + .../devtools-panel/useGraphQLRequests.ts | 1 + 5 files changed, 175 insertions(+), 60 deletions(-) diff --git a/entrypoints/devtools-panel/RequestTab.css b/entrypoints/devtools-panel/RequestTab.css index 8067ad9..9e00565 100644 --- a/entrypoints/devtools-panel/RequestTab.css +++ b/entrypoints/devtools-panel/RequestTab.css @@ -6,3 +6,7 @@ flex-direction: column; gap: var(--px-16); } + +.gt-raw-body-toggle { + margin-left: auto; +} diff --git a/entrypoints/devtools-panel/RequestTab.tsx b/entrypoints/devtools-panel/RequestTab.tsx index 728eedd..6ba155e 100644 --- a/entrypoints/devtools-panel/RequestTab.tsx +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -27,6 +27,7 @@ type Props = { export function RequestTab({ request }: Props) { const isDark = useDarkMode() const [showRaw, setShowRaw] = useState(false) + const [showRawBody, setShowRawBody] = useState(false) const formattedQuery = useMemo(() => formatQuery(request.query), [request.query]) const displayedQuery = showRaw ? request.query : formattedQuery @@ -49,76 +50,105 @@ export function RequestTab({ request }: Props) { const jsonTheme = isDark ? 'monokai' : 'rjv-default' const jsonViewStyle = { background: 'transparent', padding: '0' } + const rawBodyToggle = request.rawBody ? ( + + ) : null + return (
-
-

- Query - - -

-
-          {showRaw ? (
-            {request.query}
-          ) : (
-            
-          )}
-        
-
- - {request.variables && !(parsedVariables && Object.keys(parsedVariables).length === 0) && ( -
-

- Variables - -

- {parsedVariables ? ( -
- -
- ) : ( + {!showRawBody && ( + <> +
+

+ Query + + + {rawBodyToggle} +

-              {request.variables}
+              {showRaw ? (
+                {request.query}
+              ) : (
+                
+              )}
             
+
+ + {request.variables && !(parsedVariables && Object.keys(parsedVariables).length === 0) && ( +
+

+ Variables + +

+ {parsedVariables ? ( +
+ +
+ ) : ( +
+                  {request.variables}
+                
+ )} +
)} -
+ + {request.extensions && + !(parsedExtensions && Object.keys(parsedExtensions).length === 0) && ( +
+

+ Extensions + +

+ {parsedExtensions ? ( +
+ +
+ ) : ( +
+                    {request.extensions}
+                  
+ )} +
+ )} + )} - {request.extensions && !(parsedExtensions && Object.keys(parsedExtensions).length === 0) && ( + {showRawBody && (

- Extensions - + Raw Body + + {rawBodyToggle}

- {parsedExtensions ? ( -
- -
- ) : ( -
-              {request.extensions}
-            
- )} +
+            {request.rawBody}
+          
)}
diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx index 3daf733..a1cca97 100644 --- a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx @@ -243,4 +243,83 @@ describe('RequestTab', () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith(extensions) }) }) + + describe('Raw Body toggle', () => { + const title = 'Toggle between parsed and raw body view' + + it('toggle button is absent when rawBody is undefined', () => { + render() + expect(screen.queryByTitle(title)).not.toBeInTheDocument() + }) + + it('toggle button is present when rawBody is defined', () => { + render() + expect(screen.getByTitle(title)).toBeInTheDocument() + }) + + it('toggle is labeled "Full Raw Body" by default', () => { + render() + expect(screen.getByTitle(title)).toHaveTextContent('Full Raw Body') + }) + + it('toggle is inactive by default', () => { + render() + expect(screen.getByTitle(title)).not.toHaveClass('gt-raw-toggle--active') + }) + + it('structured sections are visible by default', () => { + render() + expect(screen.getByText('Query')).toBeInTheDocument() + }) + + it('Raw Body section is hidden by default', () => { + render() + expect(screen.queryByText('Raw Body')).not.toBeInTheDocument() + }) + + it('clicking toggle shows Raw Body section and hides structured sections', () => { + const rawBody = '{"query":"{ hero }","variables":{"id":"1"}}' + render() + fireEvent.click(screen.getByTitle(title)) + expect(screen.getByText('Raw Body')).toBeInTheDocument() + expect(screen.queryByText('Query')).not.toBeInTheDocument() + }) + + it('toggle label changes to "Parsed Body" when active', () => { + render() + fireEvent.click(screen.getByTitle(title)) + expect(screen.getByTitle(title)).toHaveTextContent('Parsed Body') + }) + + it('clicking toggle marks it as active', () => { + render() + fireEvent.click(screen.getByTitle(title)) + expect(screen.getByTitle(title)).toHaveClass('gt-raw-toggle--active') + }) + + it('clicking toggle again restores structured sections', () => { + render() + fireEvent.click(screen.getByTitle(title)) + fireEvent.click(screen.getByTitle(title)) + expect(screen.getByText('Query')).toBeInTheDocument() + expect(screen.queryByText('Raw Body')).not.toBeInTheDocument() + }) + + it('displays the raw body verbatim in a code block when toggled', () => { + const rawBody = '{"query":"{ hero }","variables":{"id":"1"}}' + render() + fireEvent.click(screen.getByTitle(title)) + const pres = document.querySelectorAll('pre') + const block = Array.from(pres).find((p) => p.textContent === rawBody) + expect(block).toBeDefined() + }) + + it('copy button in Raw Body section writes raw body to clipboard', () => { + const rawBody = '{"query":"{ hero }","variables":{"id":"1"}}' + render() + fireEvent.click(screen.getByTitle(title)) + fireEvent.click(screen.getByTitle('Copy raw body')) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(rawBody) + }) + }) }) diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index c4e54f0..05e9382 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -31,6 +31,7 @@ export type GraphQLRequest = { query: string variables?: string extensions?: string + rawBody?: string response?: string responseHeaders?: Array<{ name: string; value: string }> } diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index 79090c6..c37ba84 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -46,6 +46,7 @@ export function useGraphQLRequests(autoClear: boolean): { query, variables, extensions, + rawBody: entry.request.postData?.text || undefined, response: responseText || undefined, responseHeaders: entry.response.headers, }, From da557ce7fdef749c5b078764fb9789866263291a Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Wed, 4 Mar 2026 12:10:36 -0500 Subject: [PATCH 24/30] Add more details to request modal --- entrypoints/devtools-panel/RequestModal.css | 38 ++++++++++++++++++- entrypoints/devtools-panel/RequestModal.tsx | 24 ++++++++++-- .../__tests__/RequestModal.test.tsx | 27 +++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/entrypoints/devtools-panel/RequestModal.css b/entrypoints/devtools-panel/RequestModal.css index 891b677..574caff 100644 --- a/entrypoints/devtools-panel/RequestModal.css +++ b/entrypoints/devtools-panel/RequestModal.css @@ -22,13 +22,19 @@ .gt-modal-header { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: var(--px-4); padding: var(--px-8) var(--px-12); border-bottom: 1px solid hsla(var(--color-neutral), var(--alpha-background-heavy)); flex-shrink: 0; } +.gt-modal-header-top { + display: flex; + align-items: center; + justify-content: space-between; +} + .gt-modal-title { font-family: var(--font-family); font-size: var(--font-size-body); @@ -36,6 +42,34 @@ color: hsl(var(--color-neutral)); } +.gt-modal-meta { + display: flex; + align-items: center; + gap: var(--px-6); + font-family: var(--font-family); + font-size: var(--font-size-body); + color: hsla(var(--color-neutral), var(--alpha-tertiary)); + min-width: 0; +} + +.gt-modal-meta-method { + font-weight: 600; + flex-shrink: 0; +} + +.gt-modal-meta-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gt-modal-meta-sep { + flex-shrink: 0; + opacity: 0.4; +} + .gt-modal-close { background: none; border: none; diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx index 5a77ae3..8b8c1e0 100644 --- a/entrypoints/devtools-panel/RequestModal.tsx +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -1,3 +1,5 @@ +import { filesize } from 'filesize' +import prettyMs from 'pretty-ms' import { useEffect, useState } from 'react' import './RequestModal.css' @@ -36,10 +38,24 @@ export function RequestModal({ request, onClose }: Props) { onClick={(e) => e.stopPropagation()} >
- {request.operationName} - +
+ {request.operationName} + +
+
+ {request.method} + {request.url} + + {request.status} + · + {filesize(request.size)} + · + {prettyMs(request.time)} +
{TABS.map((tab) => ( diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx index a0c2511..13aa9e1 100644 --- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -52,6 +52,33 @@ describe('RequestModal', () => { expect(screen.getByText('MyQuery')).toBeInTheDocument() }) + describe('metadata bar', () => { + it('displays the HTTP method', () => { + renderModal(makeRequest({ method: 'POST' })) + expect(screen.getByText('POST')).toBeInTheDocument() + }) + + it('displays the URL', () => { + renderModal(makeRequest({ url: 'https://api.example.com/graphql' })) + expect(screen.getByText('https://api.example.com/graphql')).toBeInTheDocument() + }) + + it('displays the status code', () => { + renderModal(makeRequest({ status: 200 })) + expect(screen.getByText('200')).toBeInTheDocument() + }) + + it('displays the formatted size', () => { + renderModal(makeRequest({ size: 512 })) + expect(screen.getByText('512 B')).toBeInTheDocument() + }) + + it('displays the formatted time', () => { + renderModal(makeRequest({ time: 123 })) + expect(screen.getByText('123ms')).toBeInTheDocument() + }) + }) + it('renders all three tabs', () => { renderModal() expect(screen.getByRole('tab', { name: 'Headers' })).toBeInTheDocument() From 1757f21bbc03a2ef7fcbfe036bf11c2bc3763fe2 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Fri, 6 Mar 2026 14:48:12 -0500 Subject: [PATCH 25/30] Updated deps --- package.json | 4 +- pnpm-lock.yaml | 146 +++++++++++++++++++++---------------------------- 2 files changed, 63 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index c07f048..b387118 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "@graphiql/plugin-explorer": "^5.1.1", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", - "@microlink/react-json-view": "^1.27.1", + "@microlink/react-json-view": "^1.31.1", "filesize": "^11.0.13", "graphiql": "^5.2.2", + "graphql": "^16.13.1", "graphql-ws": "^6.0.7", - "graphql": "^16.13.0", "highlight.js": "^11.11.1", "pretty-ms": "^9.3.0", "react": "^19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2e4e1b..05951cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,28 +10,28 @@ importers: dependencies: '@graphiql/plugin-explorer': specifier: ^5.1.1 - version: 5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@graphiql/react': specifier: ^0.37.3 - version: 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@graphiql/toolkit': specifier: ^0.11.3 - version: 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) + version: 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1) '@microlink/react-json-view': - specifier: ^1.27.1 - version: 1.27.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.31.1 + version: 1.31.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) filesize: specifier: ^11.0.13 version: 11.0.13 graphiql: specifier: ^5.2.2 - version: 5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + version: 5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) graphql: - specifier: ^16.13.0 - version: 16.13.0 + specifier: ^16.13.1 + version: 16.13.1 graphql-ws: specifier: ^6.0.7 - version: 6.0.7(graphql@16.13.0) + version: 6.0.7(graphql@16.13.1) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -687,8 +687,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@microlink/react-json-view@1.27.1': - resolution: {integrity: sha512-HUhKcaXB8QQvOWYNBXm8p6x6SPvXzmWjc0gP6GHN+kdpWOwniUdikJe9+GHB4/tuft92idaHXTxhDJZEMW966g==} + '@microlink/react-json-view@1.31.1': + resolution: {integrity: sha512-UsWwhSgNn06RDVrhnNejFypmu3xtfpxAzQ2BqXWY7NWs5FKZnjaNDFmB5kNpC1NDjzAH2v2oDq4JFEV3eLyDkw==} engines: {node: '>=17'} peerDependencies: react: '>= 15' @@ -1548,9 +1548,6 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/base16@1.0.5': - resolution: {integrity: sha512-OzOWrTluG9cwqidEzC/Q6FAmIPcnZfm8BFRlIx0+UIUqnuAmi5OS88O0RpT3Yz6qdmqObvUhasrbNsCofE4W9A==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1748,9 +1745,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base16@1.0.0: - resolution: {integrity: sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==} - baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -1869,24 +1863,19 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} commander@2.9.0: resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==} @@ -2287,8 +2276,8 @@ packages: ws: optional: true - graphql@16.13.0: - resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} + graphql@16.13.1: + resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} growly@1.3.0: @@ -2569,8 +2558,8 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} - lodash.curry@4.1.1: - resolution: {integrity: sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2926,8 +2915,8 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-base16-styling@0.9.1: - resolution: {integrity: sha512-1s0CY1zRBOQ5M3T61wetEpvQmsYSNtWEcdYzyZNxKa8t7oDvaOn9d21xrGezGAHFWLM7SHcktPuPTrvoqxSfKw==} + react-base16-styling@0.10.0: + resolution: {integrity: sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==} react-compiler-runtime@19.1.0-rc.1: resolution: {integrity: sha512-wCt6g+cRh8g32QT18/9blfQHywGjYu+4FlEc3CW1mx3pPxYzZZl1y+VtqxRgnKKBCFLIGUYxog4j4rs5YS86hw==} @@ -3924,11 +3913,11 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/plugin-doc-explorer@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - graphql: 16.13.0 + graphql: 16.13.1 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -3938,18 +3927,18 @@ snapshots: - immer - use-sync-external-store - '@graphiql/plugin-explorer@5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@graphiql/plugin-explorer@5.1.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphiql-explorer: 0.9.0(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - graphql: 16.13.0 + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphiql-explorer: 0.9.0(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + graphql: 16.13.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/plugin-history@0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1) react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -3962,9 +3951,9 @@ snapshots: - immer - use-sync-external-store - '@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': + '@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))': dependencies: - '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0) + '@graphiql/toolkit': 0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3972,12 +3961,12 @@ snapshots: clsx: 1.2.1 framer-motion: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) get-value: 3.0.1 - graphql: 16.13.0 - graphql-language-service: 5.5.0(graphql@16.13.0) + graphql: 16.13.1 + graphql-language-service: 5.5.0(graphql@16.13.1) jsonc-parser: 3.3.1 markdown-it: 14.1.1 monaco-editor: 0.52.2 - monaco-graphql: 1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1) + monaco-graphql: 1.7.3(graphql@16.13.1)(monaco-editor@0.52.2)(prettier@3.8.1) prettier: 3.8.1 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) @@ -3993,13 +3982,13 @@ snapshots: - immer - use-sync-external-store - '@graphiql/toolkit@0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)': + '@graphiql/toolkit@0.11.3(@types/node@25.3.5)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)': dependencies: '@n1ru4l/push-pull-async-iterable-iterator': 3.2.0 - graphql: 16.13.0 + graphql: 16.13.1 meros: 1.3.2(@types/node@25.3.5) optionalDependencies: - graphql-ws: 6.0.7(graphql@16.13.0) + graphql-ws: 6.0.7(graphql@16.13.1) transitivePeerDependencies: - '@types/node' @@ -4128,10 +4117,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@microlink/react-json-view@1.27.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@microlink/react-json-view@1.31.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: react: 19.2.4 - react-base16-styling: 0.9.1 + react-base16-styling: 0.10.0 react-dom: 19.2.4(react@19.2.4) react-lifecycles-compat: 3.0.4 react-textarea-autosize: 8.5.9(@types/react@19.2.14)(react@19.2.4) @@ -4795,8 +4784,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/base16@1.0.5': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -5003,8 +4990,6 @@ snapshots: balanced-match@4.0.4: {} - base16@1.0.0: {} - baseline-browser-mapping@2.10.0: {} bidi-js@1.0.3: @@ -5126,16 +5111,10 @@ snapshots: clsx@2.1.1: {} - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} - color-name@1.1.4: {} color-string@1.9.1: @@ -5143,9 +5122,9 @@ snapshots: color-name: 1.1.4 simple-swizzle: 0.2.4 - color@3.2.1: + color@4.2.3: dependencies: - color-convert: 1.9.3 + color-convert: 2.0.1 color-string: 1.9.1 commander@2.9.0: @@ -5473,18 +5452,18 @@ snapshots: graceful-readlink@1.0.1: {} - graphiql-explorer@0.9.0(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + graphiql-explorer@0.9.0(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - graphql: 16.13.0 + graphql: 16.13.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - graphiql@5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + graphiql@5.2.2(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): dependencies: - '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.0))(graphql@16.13.0)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) - graphql: 16.13.0 + '@graphiql/plugin-doc-explorer': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.14)(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/plugin-history': 0.4.1(@graphiql/react@0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/node@25.3.5)(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + '@graphiql/react': 0.37.3(@types/node@25.3.5)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@6.0.7(graphql@16.13.1))(graphql@16.13.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + graphql: 16.13.1 react: 19.2.4 react-compiler-runtime: 19.1.0-rc.1(react@19.2.4) react-dom: 19.2.4(react@19.2.4) @@ -5497,18 +5476,18 @@ snapshots: - immer - use-sync-external-store - graphql-language-service@5.5.0(graphql@16.13.0): + graphql-language-service@5.5.0(graphql@16.13.1): dependencies: debounce-promise: 3.1.2 - graphql: 16.13.0 + graphql: 16.13.1 nullthrows: 1.1.1 vscode-languageserver-types: 3.17.5 - graphql-ws@6.0.7(graphql@16.13.0): + graphql-ws@6.0.7(graphql@16.13.1): dependencies: - graphql: 16.13.0 + graphql: 16.13.1 - graphql@16.13.0: {} + graphql@16.13.1: {} growly@1.3.0: {} @@ -5776,7 +5755,7 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 - lodash.curry@4.1.1: {} + lodash-es@4.17.23: {} lodash.includes@4.3.0: {} @@ -5882,10 +5861,10 @@ snapshots: monaco-editor@0.52.2: {} - monaco-graphql@1.7.3(graphql@16.13.0)(monaco-editor@0.52.2)(prettier@3.8.1): + monaco-graphql@1.7.3(graphql@16.13.1)(monaco-editor@0.52.2)(prettier@3.8.1): dependencies: - graphql: 16.13.0 - graphql-language-service: 5.5.0(graphql@16.13.0) + graphql: 16.13.1 + graphql-language-service: 5.5.0(graphql@16.13.1) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 prettier: 3.8.1 @@ -6176,15 +6155,12 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-base16-styling@0.9.1: + react-base16-styling@0.10.0: dependencies: - '@babel/runtime': 7.28.6 - '@types/base16': 1.0.5 '@types/lodash': 4.17.24 - base16: 1.0.0 - color: 3.2.1 + color: 4.2.3 csstype: 3.2.3 - lodash.curry: 4.1.1 + lodash-es: 4.17.23 react-compiler-runtime@19.1.0-rc.1(react@19.2.4): dependencies: From 9788f3ce3a8fb32ebaeac545fcfe2a7352db0f14 Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Sun, 8 Mar 2026 13:24:49 -0400 Subject: [PATCH 26/30] Added support for batched requests --- entrypoints/devtools-panel/App.css | 11 + entrypoints/devtools-panel/ContextMenu.tsx | 7 +- entrypoints/devtools-panel/RequestModal.css | 44 ++++ entrypoints/devtools-panel/RequestModal.tsx | 62 ++++- entrypoints/devtools-panel/RequestRow.tsx | 7 +- .../devtools-panel/__tests__/App.test.tsx | 49 +++- .../__tests__/ContextMenu.test.tsx | 60 +++++ .../__tests__/RequestModal.test.tsx | 137 ++++++++++- .../__tests__/RequestRow.test.tsx | 54 +++++ .../devtools-panel/__tests__/har.test.ts | 217 ++++++++++++++++++ .../__tests__/useDevtoolsSettings.test.ts | 2 +- .../__tests__/useGraphQLRequests.test.ts | 150 ++++++++++++ entrypoints/devtools-panel/har.ts | 110 +++++++-- .../devtools-panel/useDevtoolsSettings.ts | 2 +- .../devtools-panel/useGraphQLRequests.ts | 12 +- 15 files changed, 898 insertions(+), 26 deletions(-) diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index caef956..a219b0f 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -138,6 +138,17 @@ body { color: hsl(var(--color-neutral)); } +.gt-op-badge--batch { + background-color: #b8860b; +} + +.gt-batch-extra-count { + margin-left: var(--px-4); + font-size: 0.85em; + color: hsla(var(--color-neutral), var(--alpha-tertiary)); + flex-shrink: 0; +} + .gt-devtools-toolbar { display: flex; align-items: center; diff --git a/entrypoints/devtools-panel/ContextMenu.tsx b/entrypoints/devtools-panel/ContextMenu.tsx index 6caeaaa..62db3cd 100644 --- a/entrypoints/devtools-panel/ContextMenu.tsx +++ b/entrypoints/devtools-panel/ContextMenu.tsx @@ -66,7 +66,12 @@ export function ContextMenu({ x, y, request, onClose }: Props) { diff --git a/entrypoints/devtools-panel/RequestModal.css b/entrypoints/devtools-panel/RequestModal.css index 574caff..db668e2 100644 --- a/entrypoints/devtools-panel/RequestModal.css +++ b/entrypoints/devtools-panel/RequestModal.css @@ -35,6 +35,50 @@ justify-content: space-between; } +.gt-modal-batch-nav { + display: flex; + align-items: center; + gap: var(--px-4); + min-width: 0; +} + +.gt-modal-title-select { + padding: var(--px-4) var(--px-8); + border: none; + border-radius: var(--border-radius-4); + background: hsla(var(--color-neutral), var(--alpha-background-light)); + color: hsl(var(--color-neutral)); + font-family: var(--font-family); + font-size: var(--font-size-body); + font-weight: 600; + min-width: 0; +} + +.gt-modal-title-select:focus { + outline: hsla(var(--color-neutral), var(--alpha-background-heavy)) auto 1px; +} + +.gt-modal-batch-nav-btn { + padding: var(--px-4) var(--px-8); + border: none; + border-radius: var(--border-radius-4); + background: hsla(var(--color-neutral), var(--alpha-background-light)); + color: hsl(var(--color-neutral)); + font-size: 1.1em; + line-height: 1; + cursor: pointer; + flex-shrink: 0; +} + +.gt-modal-batch-nav-btn:hover:not(:disabled) { + background: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + +.gt-modal-batch-nav-btn:disabled { + opacity: 0.35; + cursor: default; +} + .gt-modal-title { font-family: var(--font-family); font-size: var(--font-size-body); diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx index 8b8c1e0..f6c1acb 100644 --- a/entrypoints/devtools-panel/RequestModal.tsx +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -19,6 +19,11 @@ const TABS: Tab[] = ['headers', 'request', 'response'] export function RequestModal({ request, onClose }: Props) { const [activeTab, setActiveTab] = useState('headers') + const [selectedOpIndex, setSelectedOpIndex] = useState(0) + + useEffect(() => { + setSelectedOpIndex(0) + }, [request]) useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -39,7 +44,40 @@ export function RequestModal({ request, onClose }: Props) { >
- {request.operationName} + {request.batchedOperations ? ( +
+ + + +
+ ) : ( + {request.operationName} + )} @@ -77,8 +115,26 @@ export function RequestModal({ request, onClose }: Props) {
)} - {activeTab === 'request' && } - {activeTab === 'response' && } + {(() => { + const selectedOp = request.batchedOperations?.[selectedOpIndex] + const requestForTabs: GraphQLRequest = selectedOp + ? { + ...request, + query: selectedOp.query, + variables: selectedOp.variables, + extensions: selectedOp.extensions, + response: selectedOp.response, + rawBody: undefined, + batchedOperations: undefined, + } + : request + return ( + <> + {activeTab === 'request' && } + {activeTab === 'response' && } + + ) + })()}
diff --git a/entrypoints/devtools-panel/RequestRow.tsx b/entrypoints/devtools-panel/RequestRow.tsx index 338f55b..24ffef1 100644 --- a/entrypoints/devtools-panel/RequestRow.tsx +++ b/entrypoints/devtools-panel/RequestRow.tsx @@ -38,9 +38,14 @@ export function RequestRow({ ? 'M' : req.operationType === 'subscription' ? 'S' - : 'Q'} + : req.operationType === 'batch' + ? 'B' + : 'Q'} {req.operationName} + {req.batchedOperations && req.batchedOperations.length > 1 && ( + +{req.batchedOperations.length - 1} + )}
{ }) describe('Type filter', () => { - it('renders Query and Mutation filter buttons, both initially active', () => { + it('renders Query, Mutation, and Batch filter buttons, all initially active', () => { mockHook([]) render() expect(screen.getByRole('button', { name: 'Query' })).toHaveAttribute('aria-pressed', 'true') @@ -141,6 +141,7 @@ describe('DevTools Panel App', () => { 'aria-pressed', 'true' ) + expect(screen.getByRole('button', { name: 'Batch' })).toHaveAttribute('aria-pressed', 'true') expect(screen.queryByRole('button', { name: 'Subscription' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Unknown' })).not.toBeInTheDocument() }) @@ -210,6 +211,52 @@ describe('DevTools Panel App', () => { expect(screen.getByText('Mystery')).toBeInTheDocument() expect(screen.queryByText('GetHero')).not.toBeInTheDocument() }) + + it('deactivating Batch hides batch rows but keeps query/mutation rows', async () => { + mockHook([ + makeRequest({ id: '1', operationType: 'query', operationName: 'GetHero' }), + makeRequest({ + id: '2', + operationType: 'batch', + operationName: 'GetHero', + batchedOperations: [ + { + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }, + { + operationName: 'GetVillain', + operationType: 'query', + query: 'query GetVillain { villain { name } }', + }, + ], + }), + ]) + render() + await userEvent.click(screen.getByRole('button', { name: 'Batch' })) + expect(screen.getByRole('button', { name: 'Batch' })).toHaveAttribute('aria-pressed', 'false') + expect(screen.getAllByText('GetHero')).toHaveLength(1) + }) + + it('re-clicking a deactivated Batch filter reactivates it and shows batch rows', async () => { + mockHook([ + makeRequest({ + id: '1', + operationType: 'batch', + operationName: 'BatchedOp', + batchedOperations: [ + { operationName: 'BatchedOp', operationType: 'query', query: '{ hero }' }, + ], + }), + ]) + render() + const batchBtn = screen.getByRole('button', { name: 'Batch' }) + await userEvent.click(batchBtn) + expect(screen.queryByText('BatchedOp')).not.toBeInTheDocument() + await userEvent.click(batchBtn) + expect(screen.getByText('BatchedOp')).toBeInTheDocument() + }) }) describe('Preserve log', () => { diff --git a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx index 1fc22aa..dc844ab 100644 --- a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx +++ b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx @@ -181,4 +181,64 @@ describe('ContextMenu', () => { const prevented = fireEvent.contextMenu(document.body) expect(prevented).toBe(false) }) + + describe('batch requests', () => { + function makeBatchRequest(overrides: Partial = {}): GraphQLRequest { + return makeRequest({ + operationType: 'batch', + operationName: 'GetHero', + query: '', + variables: undefined, + rawBody: + '[{"query":"query GetHero { hero { name } }"},{"query":"query GetVillain { villain { name } }"}]', + response: '[{"data":{"hero":{"name":"Luke"}}},{"data":{"villain":{"name":"Vader"}}}]', + batchedOperations: [ + { + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }, + { + operationName: 'GetVillain', + operationType: 'query', + query: 'query GetVillain { villain { name } }', + }, + ], + ...overrides, + }) + } + + it('hides Copy Variables for batch requests', () => { + renderMenu(makeBatchRequest()) + expect(screen.queryByText('Copy Variables')).not.toBeInTheDocument() + }) + + it('Copy Query copies rawBody for batch requests', async () => { + const onClose = vi.fn() + const rawBody = + '[{"query":"query GetHero { hero { name } }"},{"query":"query GetVillain { villain { name } }"}]' + renderMenu(makeBatchRequest({ rawBody }), onClose) + fireEvent.click(screen.getByText('Copy Query')) + expect(writeText).toHaveBeenCalledWith(rawBody) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Response copies the full batch response for batch requests', async () => { + const onClose = vi.fn() + renderMenu(makeBatchRequest(), onClose) + fireEvent.click(screen.getByText('Copy Response')) + expect(writeText).toHaveBeenCalledWith( + '[\n {\n "data": {\n "hero": {\n "name": "Luke"\n }\n }\n },\n {\n "data": {\n "villain": {\n "name": "Vader"\n }\n }\n }\n]' + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Query copies empty string when rawBody is undefined', async () => { + const onClose = vi.fn() + renderMenu(makeBatchRequest({ rawBody: undefined }), onClose) + fireEvent.click(screen.getByText('Copy Query')) + expect(writeText).toHaveBeenCalledWith('') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + }) }) diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx index 13aa9e1..49bc875 100644 --- a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -5,10 +5,14 @@ import '@testing-library/jest-dom/vitest' vi.mock('../RequestModal.css', () => ({})) vi.mock('../RequestTab', () => ({ - RequestTab: () =>
, + RequestTab: ({ request }: { request: { query: string } }) => ( +
+ ), })) vi.mock('../ResponseTab', () => ({ - ResponseTab: () =>
, + ResponseTab: ({ request }: { request: { response?: string } }) => ( +
+ ), })) import type { GraphQLRequest } from '../har' @@ -186,4 +190,133 @@ describe('RequestModal', () => { expect(screen.queryByTestId('response-tab-mock')).not.toBeInTheDocument() }) }) + + describe('Batch operation dropdown', () => { + function makeBatchRequest(overrides: Partial = {}): GraphQLRequest { + return makeRequest({ + operationType: 'batch', + operationName: 'GetHero', + batchedOperations: [ + { + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + response: '{"data":{"hero":{"name":"Luke"}}}', + }, + { + operationName: 'GetVillain', + operationType: 'query', + query: 'query GetVillain { villain { name } }', + response: '{"data":{"villain":{"name":"Vader"}}}', + }, + ], + ...overrides, + }) + } + + it('does not render a dropdown for non-batch requests', () => { + renderModal() + expect(screen.queryByRole('combobox', { name: 'Select operation' })).not.toBeInTheDocument() + }) + + it('renders a dropdown for batch requests with all operation names as options', () => { + renderModal(makeBatchRequest()) + const select = screen.getByRole('combobox', { name: 'Select operation' }) + expect(select).toBeInTheDocument() + const options = Array.from(select.querySelectorAll('option')).map((o) => o.textContent) + expect(options).toEqual(['GetHero', 'GetVillain']) + }) + + it('default selected option is the first operation', () => { + renderModal(makeBatchRequest()) + const select = screen.getByRole('combobox', { name: 'Select operation' }) as HTMLSelectElement + expect(select.value).toBe('0') + }) + + it('changing dropdown selection updates the Request tab query', () => { + renderModal(makeBatchRequest()) + fireEvent.click(screen.getByRole('tab', { name: 'Request' })) + expect(screen.getByTestId('request-tab-mock')).toHaveAttribute( + 'data-query', + 'query GetHero { hero { name } }' + ) + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + expect(screen.getByTestId('request-tab-mock')).toHaveAttribute( + 'data-query', + 'query GetVillain { villain { name } }' + ) + }) + + it('changing dropdown selection updates the Response tab content', () => { + renderModal(makeBatchRequest()) + fireEvent.click(screen.getByRole('tab', { name: 'Response' })) + expect(screen.getByTestId('response-tab-mock')).toHaveAttribute( + 'data-response', + '{"data":{"hero":{"name":"Luke"}}}' + ) + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + expect(screen.getByTestId('response-tab-mock')).toHaveAttribute( + 'data-response', + '{"data":{"villain":{"name":"Vader"}}}' + ) + }) + + it('Headers tab content is unaffected by dropdown selection', () => { + renderModal(makeBatchRequest()) + expect(screen.getByText('Request Headers')).toBeInTheDocument() + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + expect(screen.getByText('Request Headers')).toBeInTheDocument() + }) + + it('Previous button is disabled when first operation is selected', () => { + renderModal(makeBatchRequest()) + expect(screen.getByRole('button', { name: 'Previous operation' })).toBeDisabled() + }) + + it('Next button is disabled when last operation is selected', () => { + renderModal(makeBatchRequest()) + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + expect(screen.getByRole('button', { name: 'Next operation' })).toBeDisabled() + }) + + it('Next button advances to the next operation', () => { + renderModal(makeBatchRequest()) + fireEvent.click(screen.getByRole('button', { name: 'Next operation' })) + const select = screen.getByRole('combobox', { name: 'Select operation' }) as HTMLSelectElement + expect(select.value).toBe('1') + }) + + it('Previous button goes back to the previous operation', () => { + renderModal(makeBatchRequest()) + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Previous operation' })) + const select = screen.getByRole('combobox', { name: 'Select operation' }) as HTMLSelectElement + expect(select.value).toBe('0') + }) + + it('dropdown resets to first operation when a new request is opened', () => { + const { rerender } = renderModal(makeBatchRequest()) + fireEvent.change(screen.getByRole('combobox', { name: 'Select operation' }), { + target: { value: '1' }, + }) + const select = screen.getByRole('combobox', { name: 'Select operation' }) as HTMLSelectElement + expect(select.value).toBe('1') + + const newRequest = makeBatchRequest({ id: '2' }) + rerender() + expect( + (screen.getByRole('combobox', { name: 'Select operation' }) as HTMLSelectElement).value + ).toBe('0') + }) + }) }) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx index 30bf3e9..05d14d0 100644 --- a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -71,6 +71,60 @@ describe('RequestRow', () => { expect(screen.getByText('Q')).toHaveClass('gt-op-badge--unknown') }) + it('shows B badge for batch operations', () => { + renderRow(makeRequest({ operationType: 'batch' })) + expect(screen.getByText('B')).toHaveClass('gt-op-badge--batch') + }) + + it('shows +N annotation when batch has multiple operations', () => { + renderRow( + makeRequest({ + operationType: 'batch', + operationName: 'GetHero', + batchedOperations: [ + { + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }, + { + operationName: 'GetVillain', + operationType: 'query', + query: 'query GetVillain { villain { name } }', + }, + { + operationName: 'GetSidekick', + operationType: 'query', + query: 'query GetSidekick { sidekick { name } }', + }, + ], + }) + ) + expect(screen.getByText('+2')).toBeInTheDocument() + }) + + it('does not show +N annotation when batch has only one operation', () => { + const { container } = renderRow( + makeRequest({ + operationType: 'batch', + operationName: 'GetHero', + batchedOperations: [ + { + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }, + ], + }) + ) + expect(container.querySelector('.gt-batch-extra-count')).not.toBeInTheDocument() + }) + + it('does not show +N annotation for non-batch operations', () => { + const { container } = renderRow(makeRequest({ operationType: 'query' })) + expect(container.querySelector('.gt-batch-extra-count')).not.toBeInTheDocument() + }) + it('shows success dot for 2xx status', () => { const { container } = renderRow(makeRequest({ status: 200 })) expect(container.querySelector('.gt-status-dot--success')).toBeInTheDocument() diff --git a/entrypoints/devtools-panel/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts index 419a5ed..8afa9b0 100644 --- a/entrypoints/devtools-panel/__tests__/har.test.ts +++ b/entrypoints/devtools-panel/__tests__/har.test.ts @@ -4,6 +4,7 @@ import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables, + extractBatchedOperations, buildCurlCommand, } from '../har' import type { HAREntry, GraphQLRequest } from '../har' @@ -784,4 +785,220 @@ describe('buildCurlCommand', () => { const cmd = buildCurlCommand(makeRequest({ url: "https://example.com/it's", headers: [] })) expect(cmd).toContain("curl -X POST 'https://example.com/it'\\''s'") }) + + it('POST with rawBody → uses rawBody directly as --data-raw', () => { + const rawBody = '[{"query":"{ hero }"},{"query":"{ villain }"}]' + const cmd = buildCurlCommand( + makeRequest({ + rawBody, + headers: [{ name: 'content-type', value: 'application/json' }], + }) + ) + expect(cmd).toContain(`--data-raw '${rawBody}'`) + expect(cmd).not.toContain('"query":"query GetHero') + }) +}) + +describe('isGraphQLEntry — batch', () => { + it('POST with JSON array where all items have query → true', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { query: 'query GetHero { hero { name } }' }, + { query: 'query GetVillain { villain { name } }' }, + ]), + }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(true) + }) + + it('POST with empty array → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: '[]' }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) + + it('POST with array where some items missing query → false', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { query: 'query GetHero { hero { name } }' }, + { operationName: 'NoQuery' }, + ]), + }, + }, + }) + expect(isGraphQLEntry(entry)).toBe(false) + }) +}) + +describe('extractOperationInfo — batch', () => { + it('batch POST → returns first operation name and batch type', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { query: 'query GetHero { hero { name } }' }, + { query: 'mutation CreateUser { createUser { id } }' }, + ]), + }, + }, + }) + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'GetHero', + operationType: 'batch', + }) + }) + + it('batch POST with explicit operationName on first item → uses it', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { operationName: 'MyHero', query: 'query GetHero { hero { name } }' }, + { query: 'mutation CreateUser { createUser { id } }' }, + ]), + }, + }, + }) + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'MyHero', + operationType: 'batch', + }) + }) + + it('batch POST with anonymous first operation → uses parsed name', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { query: '{ hero { name } }' }, + { query: 'query GetVillain { villain { name } }' }, + ]), + }, + }, + }) + expect(extractOperationInfo(entry)).toEqual({ + operationName: 'Query', + operationType: 'batch', + }) + }) +}) + +describe('extractBatchedOperations', () => { + function makeBatchEntry( + items: Array<{ query: string; operationName?: string; variables?: object }>, + overrides: Partial = {} + ): HAREntry { + return makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { text: JSON.stringify(items) }, + }, + ...overrides, + }) + } + + it('parses each operation name and query from the batch array', () => { + const entry = makeBatchEntry([ + { query: 'query GetHero { hero { name } }' }, + { query: 'mutation CreateUser { createUser { id } }' }, + ]) + const ops = extractBatchedOperations(entry, undefined) + expect(ops).toHaveLength(2) + expect(ops[0]).toMatchObject({ + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }) + expect(ops[1]).toMatchObject({ + operationName: 'CreateUser', + operationType: 'mutation', + query: 'mutation CreateUser { createUser { id } }', + }) + }) + + it('uses explicit operationName when present on each item', () => { + const entry = makeBatchEntry([ + { operationName: 'MyHero', query: 'query GetHero { hero { name } }' }, + ]) + const ops = extractBatchedOperations(entry, undefined) + expect(ops[0].operationName).toBe('MyHero') + }) + + it('distributes individual responses from the batch response array', () => { + const entry = makeBatchEntry([ + { query: 'query GetHero { hero { name } }' }, + { query: 'query GetVillain { villain { name } }' }, + ]) + const responseText = JSON.stringify([ + { data: { hero: { name: 'Luke' } } }, + { data: { villain: { name: 'Vader' } } }, + ]) + const ops = extractBatchedOperations(entry, responseText) + expect(ops[0].response).toBe(JSON.stringify({ data: { hero: { name: 'Luke' } } }, null, 2)) + expect(ops[1].response).toBe(JSON.stringify({ data: { villain: { name: 'Vader' } } }, null, 2)) + }) + + it('leaves response undefined when responseText is not an array', () => { + const entry = makeBatchEntry([{ query: '{ hero { name } }' }]) + const ops = extractBatchedOperations(entry, '{"data":{"hero":{"name":"Luke"}}}') + expect(ops[0].response).toBeUndefined() + }) + + it('leaves response undefined when responseText is undefined', () => { + const entry = makeBatchEntry([{ query: '{ hero { name } }' }]) + const ops = extractBatchedOperations(entry, undefined) + expect(ops[0].response).toBeUndefined() + }) + + it('parses variables from each item', () => { + const entry = makeBatchEntry([ + { query: 'query GetHero($id: ID!) { hero(id: $id) { name } }', variables: { id: '1' } }, + ]) + const ops = extractBatchedOperations(entry, undefined) + expect(ops[0].variables).toBe('{\n "id": "1"\n}') + }) + + it('returns empty array when postData is missing', () => { + const entry = makeEntry({ + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + }, + }) + expect(extractBatchedOperations(entry, undefined)).toEqual([]) + }) + + it('returns empty array when body is not an array', () => { + const entry = makeEntry() + expect(extractBatchedOperations(entry, undefined)).toEqual([]) + }) }) diff --git a/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts index 72ca54d..b80f185 100644 --- a/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts +++ b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts @@ -50,7 +50,7 @@ describe('useDevtoolsSettings', () => { }) expect(result.current.activeTypes.has('query')).toBe(false) const stored = await fakeBrowser.storage.local.get('devtools.activeTypes') - expect(stored['devtools.activeTypes']).toEqual(['mutation']) + expect(stored['devtools.activeTypes']).toEqual(['mutation', 'batch']) }) it('toggleType reactivates a type when toggled again', async () => { diff --git a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts index 898b26a..d7448d9 100644 --- a/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts +++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts @@ -300,4 +300,154 @@ describe('useGraphQLRequests', () => { rerender({ autoClear: true }) expect(onNavigatedAddListener).toHaveBeenCalledOnce() }) + + it('onNavigated listener is not registered when autoClear is false', () => { + renderHook(() => useGraphQLRequests(false)) + expect(onNavigatedAddListener).not.toHaveBeenCalled() + }) + + it('toggling autoClear from true to false removes the navigation listener', () => { + const { rerender } = renderHook(({ autoClear }) => useGraphQLRequests(autoClear), { + initialProps: { autoClear: true }, + }) + expect(onNavigatedAddListener).toHaveBeenCalledOnce() + const registeredFn = onNavigatedAddListener.mock.calls[0][0] + rerender({ autoClear: false }) + expect(onNavigatedRemoveListener).toHaveBeenCalledWith(registeredFn) + }) + + it('stores rawBody from postData.text', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire(makeGraphQLEntry()) + }) + expect(result.current.requests[0].rawBody).toBe( + JSON.stringify({ query: 'query GetHero { hero { name } }' }) + ) + }) + + it('rawBody is undefined when there is no postData (GET request)', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire( + makeGraphQLEntry({ + method: 'GET', + url: 'https://api.example.com/graphql?query=%7B%20hero%20%7D', + headers: [], + postData: undefined, + }) + ) + }) + expect(result.current.requests[0].rawBody).toBeUndefined() + }) + + it('stores extensions when present in the request body', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire( + makeGraphQLEntry({ + postData: { + text: JSON.stringify({ + query: '{ hero }', + extensions: { persistedQuery: { version: 1 } }, + }), + }, + }) + ) + }) + expect(result.current.requests[0].extensions).toBe( + '{\n "persistedQuery": {\n "version": 1\n }\n}' + ) + }) + + it('captures a GET-based GraphQL request', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire( + makeGraphQLEntry({ + method: 'GET', + url: 'https://api.example.com/graphql?query=query%20GetHero%20%7B%20hero%20%7B%20name%20%7D%20%7D', + headers: [], + postData: undefined, + }) + ) + }) + expect(result.current.requests).toHaveLength(1) + expect(result.current.requests[0]).toMatchObject({ + method: 'GET', + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }) + }) + + it('id counter continues incrementing after clear() — does not reset', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire(makeGraphQLEntry()) + fire(makeGraphQLEntry()) + }) + act(() => { + result.current.clear() + }) + await act(async () => { + fire(makeGraphQLEntry()) + }) + expect(result.current.requests[0].id).toBe('3') + }) + + it('sets batchedOperations on batch requests with parsed operations', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + const batchBody = JSON.stringify([ + { query: 'query GetHero { hero { name } }' }, + { query: 'mutation CreateUser { createUser { id } }' }, + ]) + const batchResponse = JSON.stringify([ + { data: { hero: { name: 'Luke' } } }, + { data: { createUser: { id: '1' } } }, + ]) + await act(async () => { + fire(makeGraphQLEntry({ postData: { text: batchBody } }, batchResponse)) + }) + const req = result.current.requests[0] + expect(req.operationType).toBe('batch') + expect(req.operationName).toBe('GetHero') + expect(req.batchedOperations).toHaveLength(2) + expect(req.batchedOperations![0]).toMatchObject({ + operationName: 'GetHero', + operationType: 'query', + query: 'query GetHero { hero { name } }', + }) + expect(req.batchedOperations![1]).toMatchObject({ + operationName: 'CreateUser', + operationType: 'mutation', + query: 'mutation CreateUser { createUser { id } }', + }) + }) + + it('batchedOperations includes individual responses from the batch response array', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + const batchBody = JSON.stringify([ + { query: 'query GetHero { hero { name } }' }, + { query: 'query GetVillain { villain { name } }' }, + ]) + const batchResponse = JSON.stringify([ + { data: { hero: { name: 'Luke' } } }, + { data: { villain: { name: 'Vader' } } }, + ]) + await act(async () => { + fire(makeGraphQLEntry({ postData: { text: batchBody } }, batchResponse)) + }) + const ops = result.current.requests[0].batchedOperations! + expect(ops[0].response).toBe(JSON.stringify({ data: { hero: { name: 'Luke' } } }, null, 2)) + expect(ops[1].response).toBe(JSON.stringify({ data: { villain: { name: 'Vader' } } }, null, 2)) + }) + + it('batchedOperations is undefined for non-batch requests', async () => { + const { result } = renderHook(() => useGraphQLRequests(false)) + await act(async () => { + fire(makeGraphQLEntry()) + }) + expect(result.current.requests[0].batchedOperations).toBeUndefined() + }) }) diff --git a/entrypoints/devtools-panel/har.ts b/entrypoints/devtools-panel/har.ts index 05e9382..cbe0ddd 100644 --- a/entrypoints/devtools-panel/har.ts +++ b/entrypoints/devtools-panel/har.ts @@ -16,7 +16,16 @@ export type HAREntry = { getContent(callback: (content: string, encoding: string) => void): void } -export type OperationType = 'query' | 'mutation' | 'subscription' | 'unknown' +export type OperationType = 'query' | 'mutation' | 'subscription' | 'unknown' | 'batch' + +export type BatchedOperation = { + operationName: string + operationType: Exclude + query: string + variables?: string + extensions?: string + response?: string +} export type GraphQLRequest = { id: string @@ -34,6 +43,7 @@ export type GraphQLRequest = { rawBody?: string response?: string responseHeaders?: Array<{ name: string; value: string }> + batchedOperations?: BatchedOperation[] } export function isGraphQLEntry(entry: HAREntry): boolean { @@ -45,7 +55,14 @@ export function isGraphQLEntry(entry: HAREntry): boolean { if (!postData?.text) return false try { const body = JSON.parse(postData.text) - return typeof body.query === 'string' + if (typeof body.query === 'string') return true + if ( + Array.isArray(body) && + body.length > 0 && + body.every((item) => typeof item?.query === 'string') + ) + return true + return false } catch { return false } @@ -141,6 +158,15 @@ export function extractOperationInfo(entry: HAREntry): OperationInfo { } return info } + if (Array.isArray(body) && body.length > 0) { + const first = body[0] + const info = parseOperation(typeof first?.query === 'string' ? first.query : '') + const opName = + typeof first?.operationName === 'string' && first.operationName.trim() + ? first.operationName.trim() + : info.operationName + return { operationName: opName, operationType: 'batch' } + } } catch { // fall through } @@ -166,6 +192,56 @@ export function extractOperationInfo(entry: HAREntry): OperationInfo { return { operationName: 'Anonymous', operationType: 'unknown' } } +export function extractBatchedOperations( + entry: HAREntry, + responseText: string | undefined +): BatchedOperation[] { + const { postData } = entry.request + if (!postData?.text) return [] + try { + const body = JSON.parse(postData.text) + if (!Array.isArray(body)) return [] + + let responseArray: unknown[] | undefined + if (responseText) { + try { + const parsed = JSON.parse(responseText) + if (Array.isArray(parsed)) responseArray = parsed + } catch { + // ignore malformed response + } + } + + return body.map((item, i) => { + const info = parseOperation(typeof item?.query === 'string' ? item.query : '') + const opName = + typeof item?.operationName === 'string' && item.operationName.trim() + ? item.operationName.trim() + : info.operationName + const variables = + item?.variables !== null && typeof item?.variables === 'object' + ? JSON.stringify(item.variables, null, 2) + : undefined + const extensions = + item?.extensions !== null && typeof item?.extensions === 'object' + ? JSON.stringify(item.extensions, null, 2) + : undefined + const response = + responseArray?.[i] !== undefined ? JSON.stringify(responseArray[i], null, 2) : undefined + return { + operationName: opName, + operationType: info.operationType as Exclude, + query: typeof item?.query === 'string' ? item.query : '', + variables, + extensions, + response, + } + }) + } catch { + return [] + } +} + function parseOperation(query: string): OperationInfo { try { const ast = parse(query) @@ -203,22 +279,26 @@ export function buildCurlCommand(request: GraphQLRequest): string { } if (request.method.toUpperCase() === 'POST') { - const body: Record = { query: request.query } - if (request.variables) { - try { - body.variables = JSON.parse(request.variables) - } catch { - // variables couldn't be parsed; omit from body + if (request.rawBody) { + parts.push(`--data-raw ${shellEscape(request.rawBody)}`) + } else { + const body: Record = { query: request.query } + if (request.variables) { + try { + body.variables = JSON.parse(request.variables) + } catch { + // variables couldn't be parsed; omit from body + } } - } - if (request.extensions) { - try { - body.extensions = JSON.parse(request.extensions) - } catch { - // extensions couldn't be parsed; omit from body + if (request.extensions) { + try { + body.extensions = JSON.parse(request.extensions) + } catch { + // extensions couldn't be parsed; omit from body + } } + parts.push(`--data-raw ${shellEscape(JSON.stringify(body))}`) } - parts.push(`--data-raw ${shellEscape(JSON.stringify(body))}`) } return parts.join(' ') diff --git a/entrypoints/devtools-panel/useDevtoolsSettings.ts b/entrypoints/devtools-panel/useDevtoolsSettings.ts index c9920a0..458c74c 100644 --- a/entrypoints/devtools-panel/useDevtoolsSettings.ts +++ b/entrypoints/devtools-panel/useDevtoolsSettings.ts @@ -4,7 +4,7 @@ import { storage } from '#imports' import type { OperationType } from './har' -export const FILTER_TYPES: OperationType[] = ['query', 'mutation'] +export const FILTER_TYPES: OperationType[] = ['query', 'mutation', 'batch'] export const DEFAULT_COLUMN_WIDTHS = [200, 100, 100, 100] const preserveLogItem = storage.defineItem('local:devtools.preserveLog', { diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts index c37ba84..693d691 100644 --- a/entrypoints/devtools-panel/useGraphQLRequests.ts +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -1,7 +1,12 @@ import { useState, useEffect, useCallback } from 'react' import { browser } from 'wxt/browser' -import { isGraphQLEntry, extractOperationInfo, extractQueryAndVariables } from './har' +import { + isGraphQLEntry, + extractOperationInfo, + extractQueryAndVariables, + extractBatchedOperations, +} from './har' import type { HAREntry, GraphQLRequest } from './har' export function useGraphQLRequests(autoClear: boolean): { @@ -31,6 +36,10 @@ export function useGraphQLRequests(autoClear: boolean): { } }) }) + const batchedOperations = + operationType === 'batch' + ? extractBatchedOperations(entry, responseText || undefined) + : undefined setRequests((prev) => [ ...prev, { @@ -49,6 +58,7 @@ export function useGraphQLRequests(autoClear: boolean): { rawBody: entry.request.postData?.text || undefined, response: responseText || undefined, responseHeaders: entry.response.headers, + batchedOperations, }, ]) } From 32061c8d018dfa5087cd3f0cb6776f3c144aabcf Mon Sep 17 00:00:00 2001 From: Jason Simeone Date: Sun, 8 Mar 2026 13:40:38 -0400 Subject: [PATCH 27/30] Display the operation type badge in the modal --- entrypoints/devtools-panel/App.css | 36 --------------------- entrypoints/devtools-panel/OpTypeBadge.css | 35 ++++++++++++++++++++ entrypoints/devtools-panel/OpTypeBadge.tsx | 12 +++++++ entrypoints/devtools-panel/RequestModal.css | 2 ++ entrypoints/devtools-panel/RequestModal.tsx | 7 +++- entrypoints/devtools-panel/RequestRow.tsx | 11 ++----- 6 files changed, 57 insertions(+), 46 deletions(-) create mode 100644 entrypoints/devtools-panel/OpTypeBadge.css create mode 100644 entrypoints/devtools-panel/OpTypeBadge.tsx diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css index a219b0f..874da89 100644 --- a/entrypoints/devtools-panel/App.css +++ b/entrypoints/devtools-panel/App.css @@ -106,42 +106,6 @@ body { background-color: hsl(var(--color-error)); } -.gt-op-badge { - display: flex; - align-items: center; - justify-content: center; - width: 1.25em; - height: 1.25em; - border-radius: var(--border-radius-4); - font-size: 0.7em; - font-weight: 700; - margin-right: var(--px-6); - flex-shrink: 0; - color: white; -} - -.gt-op-badge--query { - background-color: #4b9eed; -} - -.gt-op-badge--mutation { - background-color: hsl(var(--color-primary)); -} - -.gt-op-badge--subscription { - background-color: #4b9eed; - opacity: 0.65; -} - -.gt-op-badge--unknown { - background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); - color: hsl(var(--color-neutral)); -} - -.gt-op-badge--batch { - background-color: #b8860b; -} - .gt-batch-extra-count { margin-left: var(--px-4); font-size: 0.85em; diff --git a/entrypoints/devtools-panel/OpTypeBadge.css b/entrypoints/devtools-panel/OpTypeBadge.css new file mode 100644 index 0000000..9f21de5 --- /dev/null +++ b/entrypoints/devtools-panel/OpTypeBadge.css @@ -0,0 +1,35 @@ +.gt-op-badge { + display: flex; + align-items: center; + justify-content: center; + width: 1.25em; + height: 1.25em; + border-radius: var(--border-radius-4); + font-size: 0.7em; + font-weight: 700; + margin-right: var(--px-6); + flex-shrink: 0; + color: white; +} + +.gt-op-badge--query { + background-color: #4b9eed; +} + +.gt-op-badge--mutation { + background-color: hsl(var(--color-primary)); +} + +.gt-op-badge--subscription { + background-color: #4b9eed; + opacity: 0.65; +} + +.gt-op-badge--unknown { + background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); + color: hsl(var(--color-neutral)); +} + +.gt-op-badge--batch { + background-color: #b8860b; +} diff --git a/entrypoints/devtools-panel/OpTypeBadge.tsx b/entrypoints/devtools-panel/OpTypeBadge.tsx new file mode 100644 index 0000000..3fc357f --- /dev/null +++ b/entrypoints/devtools-panel/OpTypeBadge.tsx @@ -0,0 +1,12 @@ +import './OpTypeBadge.css' +import type { OperationType } from './har' + +type Props = { + type: OperationType +} + +export function OpTypeBadge({ type }: Props) { + const label = + type === 'mutation' ? 'M' : type === 'subscription' ? 'S' : type === 'batch' ? 'B' : 'Q' + return {label} +} diff --git a/entrypoints/devtools-panel/RequestModal.css b/entrypoints/devtools-panel/RequestModal.css index db668e2..3c81c9a 100644 --- a/entrypoints/devtools-panel/RequestModal.css +++ b/entrypoints/devtools-panel/RequestModal.css @@ -80,6 +80,8 @@ } .gt-modal-title { + display: flex; + align-items: center; font-family: var(--font-family); font-size: var(--font-size-body); font-weight: 600; diff --git a/entrypoints/devtools-panel/RequestModal.tsx b/entrypoints/devtools-panel/RequestModal.tsx index f6c1acb..d2948e8 100644 --- a/entrypoints/devtools-panel/RequestModal.tsx +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' import './RequestModal.css' import type { GraphQLRequest } from './har' import { HeadersTable } from './HeadersTable' +import { OpTypeBadge } from './OpTypeBadge' import { RequestTab } from './RequestTab' import { ResponseTab } from './ResponseTab' @@ -46,6 +47,7 @@ export function RequestModal({ request, onClose }: Props) {
{request.batchedOperations ? (
+