diff --git a/CHANGELOG.md b/CHANGELOG.md index bb70704..7453fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ ## Unreleased +- Added a DevTools panel for capturing and examining GraphQL requests. - Added ability to open saved queries in a new GraphiQL tab instead of overwriting the active tab. - Fixed issue with WebSocket-based subscriptions not working at all. -- Minor styling fixes +- Minor styling fixes. ## 0.1.0 (2026-02-19) diff --git a/CLAUDE.md b/CLAUDE.md index bf7d104..ce9ac1c 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 @@ -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/e2e/devtools.spec.ts b/e2e/devtools.spec.ts new file mode 100644 index 0000000..8247a10 --- /dev/null +++ b/e2e/devtools.spec.ts @@ -0,0 +1,320 @@ +import type { Page } from '@playwright/test' + +import { test, expect } from './fixtures' + +// --------------------------------------------------------------------------- +// Fake HAR entries – synthetic GraphQL requests used to populate the panel +// --------------------------------------------------------------------------- + +const QUERY_ENTRY = { + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [ + { name: 'content-type', value: 'application/json' }, + { name: 'accept', value: 'application/json' }, + ], + postData: { + text: JSON.stringify({ + operationName: 'GetItems', + query: 'query GetItems { items { id name } }', + }), + }, + }, + response: { + status: 200, + content: { size: 512 }, + headers: [{ name: 'content-type', value: 'application/json' }], + }, + time: 150, + responseContent: JSON.stringify({ data: { items: [{ id: '1', name: 'Item One' }] } }), +} + +const MUTATION_ENTRY = { + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify({ + operationName: 'CreateItem', + query: 'mutation CreateItem($name: String!) { createItem(name: $name) { id } }', + variables: { name: 'Test Item' }, + }), + }, + }, + response: { + status: 200, + content: { size: 256 }, + headers: [{ name: 'content-type', value: 'application/json' }], + }, + time: 80, + responseContent: JSON.stringify({ data: { createItem: { id: '2' } } }), +} + +const ERROR_ENTRY = { + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify({ + operationName: 'FailQuery', + query: 'query FailQuery { fail }', + }), + }, + }, + response: { + status: 500, + content: { size: 64 }, + headers: [], + }, + time: 10, + responseContent: JSON.stringify({ errors: [{ message: 'Internal Server Error' }] }), +} + +const BATCH_ENTRY = { + request: { + method: 'POST', + url: 'https://example.com/graphql', + headers: [{ name: 'content-type', value: 'application/json' }], + postData: { + text: JSON.stringify([ + { operationName: 'GetItems', query: 'query GetItems { items { id name } }' }, + { operationName: 'GetOther', query: 'query GetOther { other { id } }' }, + ]), + }, + }, + response: { + status: 200, + content: { size: 1024 }, + headers: [{ name: 'content-type', value: 'application/json' }], + }, + time: 200, + responseContent: JSON.stringify([ + { data: { items: [{ id: '1', name: 'Item One' }] } }, + { data: { other: [{ id: '2' }] } }, + ]), +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function addRequest(page: Page, data: unknown) { + await page.evaluate((d) => (window as any).__addGraphQLRequest(d), data) +} + +async function triggerNavigated(page: Page) { + await page.evaluate(() => (window as any).__triggerNavigated()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('DevTools Panel', () => { + test.describe('Empty state', () => { + test('shows "No GraphQL requests recorded." by default', async ({ devtoolsPanel: page }) => { + await expect(page.locator('.gt-network-empty')).toContainText('No GraphQL requests recorded.') + }) + }) + + test.describe('Request list', () => { + test('shows a query request with operation name and Q badge', async ({ + devtoolsPanel: page, + }) => { + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-network-row')).toBeVisible() + await expect(page.locator('.gt-op-badge--query')).toBeVisible() + await expect(page.locator('.gt-network-row')).toContainText('GetItems') + }) + + test('shows a mutation request with M badge', async ({ devtoolsPanel: page }) => { + await addRequest(page, MUTATION_ENTRY) + await expect(page.locator('.gt-op-badge--mutation')).toBeVisible() + await expect(page.locator('.gt-network-row')).toContainText('CreateItem') + }) + + test('shows a batch request with B badge', async ({ devtoolsPanel: page }) => { + await addRequest(page, BATCH_ENTRY) + await expect(page.locator('.gt-op-badge--batch')).toBeVisible() + }) + + test('shows a success status indicator for 2xx responses', async ({ devtoolsPanel: page }) => { + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-status-dot--success')).toBeVisible() + }) + + test('shows an error status indicator for 5xx responses', async ({ devtoolsPanel: page }) => { + await addRequest(page, ERROR_ENTRY) + await expect(page.locator('.gt-status-dot--error')).toBeVisible() + }) + }) + + test.describe('Clear button', () => { + test('removes all requests and shows the empty state', async ({ devtoolsPanel: page }) => { + await addRequest(page, QUERY_ENTRY) + await addRequest(page, MUTATION_ENTRY) + await expect(page.locator('.gt-network-row')).toHaveCount(2) + await page.locator('.gt-clear-btn').click() + await expect(page.locator('.gt-network-empty')).toBeVisible() + }) + }) + + test.describe('Type filters', () => { + test.beforeEach(async ({ devtoolsPanel: page }) => { + await addRequest(page, QUERY_ENTRY) + await addRequest(page, MUTATION_ENTRY) + await expect(page.locator('.gt-network-row')).toHaveCount(2) + }) + + test('toggling off Query hides query requests', async ({ devtoolsPanel: page }) => { + await page.getByRole('button', { name: 'Query' }).click() + await expect(page.locator('.gt-network-row')).toHaveCount(1) + await expect(page.locator('.gt-op-badge--mutation')).toBeVisible() + }) + + test('re-enabling Query restores query requests', async ({ devtoolsPanel: page }) => { + await page.getByRole('button', { name: 'Query' }).click() + await expect(page.locator('.gt-network-row')).toHaveCount(1) + await page.getByRole('button', { name: 'Query' }).click() + await expect(page.locator('.gt-network-row')).toHaveCount(2) + }) + + test('toggling off Mutation hides mutation requests', async ({ devtoolsPanel: page }) => { + await page.getByRole('button', { name: 'Mutation' }).click() + await expect(page.locator('.gt-network-row')).toHaveCount(1) + await expect(page.locator('.gt-op-badge--query')).toBeVisible() + }) + }) + + test.describe('Request modal', () => { + test.beforeEach(async ({ devtoolsPanel: page }) => { + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-network-row')).toBeVisible() + }) + + test('clicking a row opens the modal', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-backdrop')).toBeVisible() + }) + + test('modal shows the operation name', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-title')).toContainText('GetItems') + }) + + test('modal meta shows the request URL', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-meta-url')).toContainText('https://example.com/graphql') + }) + + test('Headers tab shows request headers', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + // Headers is the default tab + await expect(page.locator('.gt-modal-headers')).toContainText('content-type') + }) + + test('Request tab shows the query', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await page.getByRole('tab', { name: 'Request' }).click() + await expect(page.locator('.gt-query-block')).toContainText('GetItems') + }) + + test('Response tab shows the response data', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await page.getByRole('tab', { name: 'Response' }).click() + await expect(page.locator('.gt-modal-content')).toContainText('Item One') + }) + + test('Escape key closes the modal', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-backdrop')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('.gt-modal-backdrop')).not.toBeVisible() + }) + + test('close button closes the modal', async ({ devtoolsPanel: page }) => { + await page.locator('.gt-network-row').click() + await page.locator('.gt-modal-close').click() + await expect(page.locator('.gt-modal-backdrop')).not.toBeVisible() + }) + + test('next/prev buttons navigate between requests', async ({ devtoolsPanel: page }) => { + await addRequest(page, MUTATION_ENTRY) + await expect(page.locator('.gt-network-row')).toHaveCount(2) + + await page.locator('.gt-network-row').first().click() + await expect(page.locator('.gt-modal-title')).toContainText('GetItems') + + await page.getByLabel('Next request').click() + await expect(page.locator('.gt-modal-title')).toContainText('CreateItem') + + await page.getByLabel('Previous request').click() + await expect(page.locator('.gt-modal-title')).toContainText('GetItems') + }) + }) + + test.describe('Preserve log', () => { + test('clears requests on navigation when preserve log is off', async ({ + devtoolsPanel: page, + }) => { + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-network-row')).toBeVisible() + await triggerNavigated(page) + await expect(page.locator('.gt-network-empty')).toBeVisible() + }) + + test('keeps requests on navigation when preserve log is on', async ({ + devtoolsPanel: page, + }) => { + await page.locator('.gt-toolbar-label input[type="checkbox"]').check() + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-network-row')).toBeVisible() + await triggerNavigated(page) + await expect(page.locator('.gt-network-row')).toBeVisible() + }) + }) + + test.describe('Context menu', () => { + test('right-clicking a row shows the context menu with copy actions', async ({ + devtoolsPanel: page, + }) => { + await addRequest(page, QUERY_ENTRY) + await expect(page.locator('.gt-network-row')).toBeVisible() + await page.locator('.gt-network-row').click({ button: 'right' }) + await expect(page.locator('.gt-context-menu')).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Copy URL' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Copy Query' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Copy as cURL' })).toBeVisible() + }) + }) + + test.describe('Batch requests', () => { + test('batch modal shows operation selector with both operations', async ({ + devtoolsPanel: page, + }) => { + await addRequest(page, BATCH_ENTRY) + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-batch-nav')).toBeVisible() + const select = page.locator('.gt-modal-title-select') + await expect(select).toBeVisible() + await expect(select.locator('option')).toHaveCount(2) + }) + + test('batch modal prev/next buttons navigate between operations', async ({ + devtoolsPanel: page, + }) => { + await addRequest(page, BATCH_ENTRY) + await page.locator('.gt-network-row').click() + await expect(page.locator('.gt-modal-title-select')).toHaveValue('0') + + await page.getByLabel('Next operation').click() + await expect(page.locator('.gt-modal-title-select')).toHaveValue('1') + + await page.getByLabel('Previous operation').click() + await expect(page.locator('.gt-modal-title-select')).toHaveValue('0') + }) + }) +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 637601d..a63a3ed 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,10 +1,11 @@ import path from 'path' -import { test as base, chromium, type BrowserContext } from '@playwright/test' +import { test as base, chromium, type BrowserContext, type Page } from '@playwright/test' export const test = base.extend<{ context: BrowserContext extensionId: string + devtoolsPanel: Page }>({ // eslint-disable-next-line no-empty-pattern -- Playwright fixture convention context: async ({}, use) => { @@ -27,6 +28,50 @@ export const test = base.extend<{ const extensionId = serviceWorker.url().split('/')[2] await use(extensionId) }, + devtoolsPanel: async ({ page, extensionId }, use) => { + await page.addInitScript(` + (function () { + localStorage.clear(); + var requestListeners = []; + var navigatedListeners = []; + + window.__addGraphQLRequest = function (data) { + var entry = { + request: data.request, + response: data.response, + time: data.time, + getContent: function (cb) { cb(data.responseContent || '', ''); } + }; + return Promise.all(requestListeners.map(function (l) { return l(entry); })); + }; + + window.__triggerNavigated = function () { + navigatedListeners.forEach(function (l) { l(); }); + }; + + window.chrome.devtools = { + network: { + onRequestFinished: { + addListener: function (cb) { requestListeners.push(cb); }, + removeListener: function (cb) { + requestListeners = requestListeners.filter(function (l) { return l !== cb; }); + } + }, + onNavigated: { + addListener: function (cb) { navigatedListeners.push(cb); }, + removeListener: function (cb) { + navigatedListeners = navigatedListeners.filter(function (l) { return l !== cb; }); + } + } + }, + inspectedWindow: { tabId: 1 } + }; + })(); + `) + await page.goto(`chrome-extension://${extensionId}/devtools-panel.html`) + await page.waitForSelector('.gt-devtools-panel') + await use(page) + }, }) export const expect = test.expect diff --git a/entrypoints/devtools-panel/App.css b/entrypoints/devtools-panel/App.css new file mode 100644 index 0000000..874da89 --- /dev/null +++ b/entrypoints/devtools-panel/App.css @@ -0,0 +1,171 @@ +html, +body { + height: 100%; + margin: 0; +} + +#root { + height: 100%; +} + +.graphiql-container { + height: 100%; +} + +.gt-devtools-panel { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + font-family: var(--font-family); + font-size: var(--font-size-body); + overflow: hidden; +} + +.gt-network-header, +.gt-network-row { + display: grid; + grid-template-columns: var(--gt-col-widths, 200px 100px 100px 100px 1fr); + align-items: center; +} + +.gt-network-header > div, +.gt-network-row > div { + padding: var(--px-6) var(--px-8); + white-space: nowrap; + 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 { + font-weight: 600; + color: hsl(var(--color-neutral)); + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + flex-shrink: 0; +} + +.gt-network-row:hover { + background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + cursor: pointer; +} + +.gt-network-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.gt-network-empty { + text-align: center; + color: hsla(var(--color-neutral), var(--alpha-tertiary)); + padding: var(--px-16); +} + +.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-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; + 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-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); +} + +.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 new file mode 100644 index 0000000..1205f2a --- /dev/null +++ b/entrypoints/devtools-panel/App.tsx @@ -0,0 +1,198 @@ +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 { RequestModal } from './RequestModal' +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, columnWidths, setColumnWidths } = + useDevtoolsSettings() + const { requests, clear } = useGraphQLRequests(!preserveLog) + + const visible = requests.filter( + (r) => + !FILTER_TYPES.includes(r.operationType as (typeof FILTER_TYPES)[number]) || + activeTypes.has(r.operationType) + ) + + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + request: GraphQLRequest + } | null>(null) + const [selectedRequest, setSelectedRequest] = useState(null) + + 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 ( +
+
+
+
+ + +
+
+ {FILTER_TYPES.map((type) => ( + + ))} +
+
+
+
+ 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
+
+
+ {visible.length === 0 ? ( +
No GraphQL requests recorded.
+ ) : ( + + rowComponent={RequestRow} + rowCount={visible.length} + rowHeight={ROW_HEIGHT} + rowProps={{ + visible, + onContextMenu: (req, x, y) => setContextMenu({ x, y, request: req }), + onClick: (req) => setSelectedRequest(req), + }} + style={{ height: '100%' }} + /> + )} +
+
+ {contextMenu && ( + setContextMenu(null)} + /> + )} + {selectedRequest && + (() => { + const selectedIndex = visible.findIndex((r) => r.id === selectedRequest.id) + return ( + setSelectedRequest(null)} + onPrev={ + selectedIndex > 0 ? () => setSelectedRequest(visible[selectedIndex - 1]) : undefined + } + onNext={ + selectedIndex < visible.length - 1 + ? () => setSelectedRequest(visible[selectedIndex + 1]) + : undefined + } + /> + ) + })()} +
+ ) +} 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/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..62db3cd --- /dev/null +++ b/entrypoints/devtools-panel/ContextMenu.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef } from 'react' + +import './ContextMenu.css' +import { buildCurlCommand } from './har' +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) + .catch(() => {}) + .finally(onClose) +} + +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/CopyButton.tsx b/entrypoints/devtools-panel/CopyButton.tsx new file mode 100644 index 0000000..feb5909 --- /dev/null +++ b/entrypoints/devtools-panel/CopyButton.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react' + +import { CheckIcon } from './CheckIcon' +import { CopyIcon } from './CopyIcon' + +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/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 = () => ( + +) diff --git a/entrypoints/devtools-panel/HeadersTable.tsx b/entrypoints/devtools-panel/HeadersTable.tsx new file mode 100644 index 0000000..ed51922 --- /dev/null +++ b/entrypoints/devtools-panel/HeadersTable.tsx @@ -0,0 +1,47 @@ +import { CopyButton } from './CopyButton' + +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 && ( + `${h.name}: ${h.value}`).join('\n')} + title="Copy all headers" + /> + )} +

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

No headers

+ )} +
+ ) +} diff --git a/entrypoints/devtools-panel/ModalActionsMenu.css b/entrypoints/devtools-panel/ModalActionsMenu.css new file mode 100644 index 0000000..2014f6d --- /dev/null +++ b/entrypoints/devtools-panel/ModalActionsMenu.css @@ -0,0 +1,28 @@ +.gt-modal-actions-menu { + position: absolute; + top: 100%; + right: 0; + z-index: 1002; + 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-modal-actions-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-modal-actions-menu-item:hover { + background: hsla(var(--color-neutral), var(--alpha-background-light)); +} diff --git a/entrypoints/devtools-panel/ModalActionsMenu.tsx b/entrypoints/devtools-panel/ModalActionsMenu.tsx new file mode 100644 index 0000000..78f0730 --- /dev/null +++ b/entrypoints/devtools-panel/ModalActionsMenu.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react' + +import './ModalActionsMenu.css' +import { buildCurlCommand } from './har' +import type { GraphQLRequest } from './har' + +type Props = { + request: GraphQLRequest + onClose: () => void +} + +function copyAndClose(text: string, onClose: () => void) { + navigator.clipboard + .writeText(text) + .catch(() => {}) + .finally(onClose) +} + +function requestBody(request: GraphQLRequest): string { + if (request.rawBody) return request.rawBody + const body: Record = { query: request.query } + if (request.variables) { + try { + body.variables = JSON.parse(request.variables) + } catch { + // omit unparseable variables + } + } + if (request.extensions) { + try { + body.extensions = JSON.parse(request.extensions) + } catch { + // omit unparseable extensions + } + } + return JSON.stringify(body, null, 2) +} + +export function ModalActionsMenu({ request, onClose }: Props) { + const menuRef = useRef(null) + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + function handleMouseDown(e: MouseEvent) { + if (!menuRef.current?.contains(e.target as Node)) onClose() + } + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleMouseDown) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleMouseDown) + } + }, [onClose]) + + const hasBody = request.rawBody !== undefined || request.method.toUpperCase() === 'POST' + + return ( +
+ + {hasBody && ( + + )} + {request.response && ( + + )} + +
+ ) +} 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 new file mode 100644 index 0000000..d4f1a85 --- /dev/null +++ b/entrypoints/devtools-panel/RequestModal.css @@ -0,0 +1,282 @@ +.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; + 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-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 { + display: flex; + align-items: center; + font-family: var(--font-family); + font-size: var(--font-size-body); + font-weight: 600; + 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-header-actions { + display: flex; + align-items: center; + gap: var(--px-4); + flex-shrink: 0; +} + +.gt-modal-actions-anchor { + position: relative; +} + +.gt-modal-nav-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + color: hsl(var(--color-neutral)); + opacity: 0.75; + padding: var(--px-4) var(--px-6); + border-radius: var(--border-radius-4); +} + +.gt-modal-nav-btn:hover:not(:disabled) { + opacity: 1; + background-color: hsla(var(--color-neutral), var(--alpha-background-medium)); +} + +.gt-modal-nav-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.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; +} + +.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 new file mode 100644 index 0000000..2287295 --- /dev/null +++ b/entrypoints/devtools-panel/RequestModal.tsx @@ -0,0 +1,185 @@ +import { filesize } from 'filesize' +import prettyMs from 'pretty-ms' +import { useEffect, useState } from 'react' + +import './RequestModal.css' +import type { GraphQLRequest } from './har' +import { HeadersTable } from './HeadersTable' +import { ModalActionsMenu } from './ModalActionsMenu' +import { OpTypeBadge } from './OpTypeBadge' +import { RequestTab } from './RequestTab' +import { ResponseTab } from './ResponseTab' + +type Tab = 'headers' | 'request' | 'response' + +type Props = { + request: GraphQLRequest + onClose: () => void + onPrev?: () => void + onNext?: () => void +} + +const TABS: Tab[] = ['headers', 'request', 'response'] + +export function RequestModal({ request, onClose, onPrev, onNext }: Props) { + const [activeTab, setActiveTab] = useState('headers') + const [selectedOpIndex, setSelectedOpIndex] = useState(0) + const [actionsMenuOpen, setActionsMenuOpen] = useState(false) + + useEffect(() => { + setSelectedOpIndex(0) + }, [request]) + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowLeft') onPrev?.() + if (e.key === 'ArrowRight') onNext?.() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose, onPrev, onNext]) + + return ( +
+
e.stopPropagation()} + > +
+
+ {request.batchedOperations ? ( +
+ + + + +
+ ) : ( + + + {request.operationName} + + )} +
+ + +
+ + {actionsMenuOpen && ( + setActionsMenuOpen(false)} /> + )} +
+ +
+
+
+ {request.method} + {request.url} + + {request.status} + · + {filesize(request.size)} + · + {prettyMs(request.time)} +
+
+
+ {TABS.map((tab) => ( + + ))} +
+
+ {activeTab === 'headers' && ( +
+ + +
+ )} + {(() => { + 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 new file mode 100644 index 0000000..d29089b --- /dev/null +++ b/entrypoints/devtools-panel/RequestRow.tsx @@ -0,0 +1,54 @@ +import { filesize } from 'filesize' +import prettyMs from 'pretty-ms' +import type { RowComponentProps } from 'react-window' + +import type { GraphQLRequest } from './har' +import { OpTypeBadge } from './OpTypeBadge' + +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({ + index, + style, + ariaAttributes, + visible, + onContextMenu, + onClick, +}: RowComponentProps) { + const req = visible[index] + return ( +
onClick(req)} + onContextMenu={(e) => { + e.preventDefault() + onContextMenu(req, e.clientX, e.clientY) + }} + > +
+ + {req.operationName} + {req.batchedOperations && req.batchedOperations.length > 1 && ( + +{req.batchedOperations.length - 1} + )} +
+
+ + {req.status} +
+
{filesize(req.size)}
+
{prettyMs(req.time)}
+
{req.url}
+
+ ) +} diff --git a/entrypoints/devtools-panel/RequestTab.css b/entrypoints/devtools-panel/RequestTab.css new file mode 100644 index 0000000..9e00565 --- /dev/null +++ b/entrypoints/devtools-panel/RequestTab.css @@ -0,0 +1,12 @@ +@import './tab-shared.css'; + +.gt-request-tab { + padding: var(--px-8) var(--px-12); + display: flex; + 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 new file mode 100644 index 0000000..6ba155e --- /dev/null +++ b/entrypoints/devtools-panel/RequestTab.tsx @@ -0,0 +1,156 @@ +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 { CopyButton } from './CopyButton' +import { parseJsonObject } from './har' +import type { GraphQLRequest } from './har' +import { useDarkMode } from './useDarkMode' + +hljs.registerLanguage('graphql', graphql) + +function formatQuery(raw: string): string { + try { + return print(parse(raw)) + } catch { + return raw + } +} + +type Props = { + request: GraphQLRequest +} + +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 + + 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' } + + const rawBodyToggle = request.rawBody ? ( + + ) : null + + return ( +
+ {!showRawBody && ( + <> +
+

+ Query + + + {rawBodyToggle} +

+
+              {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}
+                  
+ )} +
+ )} + + )} + + {showRawBody && ( +
+

+ Raw Body + + {rawBodyToggle} +

+
+            {request.rawBody}
+          
+
+ )} +
+ ) +} 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..f98fb8f --- /dev/null +++ b/entrypoints/devtools-panel/ResponseTab.tsx @@ -0,0 +1,75 @@ +import ReactJsonView from '@microlink/react-json-view' +import { useMemo, useState } from 'react' + +import './ResponseTab.css' +import { CopyButton } from './CopyButton' +import { parseJsonObject } from './har' +import type { GraphQLRequest } from './har' +import { useDarkMode } from './useDarkMode' + +type Props = { + request: GraphQLRequest +} + +export function ResponseTab({ request }: Props) { + const isDark = useDarkMode() + 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__/App.test.tsx b/entrypoints/devtools-panel/__tests__/App.test.tsx new file mode 100644 index 0000000..241712f --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/App.test.tsx @@ -0,0 +1,515 @@ +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' +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('../ContextMenu.css', () => ({})) +vi.mock('../RequestModal.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' +import type { GraphQLRequest } from '../har' +import { useGraphQLRequests } from '../useGraphQLRequests' + +const mockUseGraphQLRequests = vi.mocked(useGraphQLRequests) + +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: [], + query: 'query GetHero { hero { name } }', + ...overrides, + } +} + +function mockHook(requests: GraphQLRequest[], clear = vi.fn()) { + mockUseGraphQLRequests.mockReturnValue({ requests, clear }) +} + +describe('DevTools Panel App', () => { + beforeEach(() => { + fakeBrowser.reset() + }) + + afterEach(() => { + cleanup() + }) + + 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() + }) + }) + + 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) + }) + }) + + 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() + }) + }) + + describe('Type filter', () => { + it('renders Query, Mutation, and Batch filter buttons, all 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: 'Batch' })).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('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', () => { + 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) + }) + }) + + 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('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') + }) + }) + + 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() + }) + }) + + 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('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('Previous request button is disabled when the first request is open', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[0]) + expect(screen.getByRole('button', { name: 'Previous request' })).toBeDisabled() + }) + + it('Next request button is disabled when the last request is open', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[1]) + expect(screen.getByRole('button', { name: 'Next request' })).toBeDisabled() + }) + + it('clicking Next request advances to the next request in the list', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[0]) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero') + fireEvent.click(screen.getByRole('button', { name: 'Next request' })) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'CreateUser') + }) + + it('clicking Previous request goes back to the previous request in the list', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[1]) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'CreateUser') + fireEvent.click(screen.getByRole('button', { name: 'Previous request' })) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero') + }) + + it('ArrowRight navigates to the next request while the modal is open', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[0]) + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'CreateUser') + }) + + it('ArrowLeft navigates to the previous request while the modal is open', () => { + mockHook([ + makeRequest({ id: '1', operationName: 'GetHero' }), + makeRequest({ id: '2', operationName: 'CreateUser' }), + ]) + render() + const rows = document.querySelectorAll('.gt-network-row') + fireEvent.click(rows[1]) + fireEvent.keyDown(document, { key: 'ArrowLeft' }) + expect(screen.getByRole('dialog')).toHaveAttribute('aria-label', 'GetHero') + }) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx new file mode 100644 index 0000000..dc844ab --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/ContextMenu.test.tsx @@ -0,0 +1,244 @@ +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', + method: 'POST', + headers: [{ name: 'content-type', value: 'application/json' }], + 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('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) + 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('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) + 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) + }) + + 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__/CopyButton.test.tsx b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx new file mode 100644 index 0000000..c3109e6 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/CopyButton.test.tsx @@ -0,0 +1,92 @@ +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') + }) + + 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__/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__/ModalActionsMenu.test.tsx b/entrypoints/devtools-panel/__tests__/ModalActionsMenu.test.tsx new file mode 100644 index 0000000..422ae68 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/ModalActionsMenu.test.tsx @@ -0,0 +1,218 @@ +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('../ModalActionsMenu.css', () => ({})) + +import type { GraphQLRequest } from '../har' +import { ModalActionsMenu } from '../ModalActionsMenu' + +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 } }', + response: '{"data":{"hero":{"name":"Luke"}}}', + ...overrides, + } +} + +function renderMenu(req: GraphQLRequest, onClose = vi.fn()) { + return render() +} + +describe('ModalActionsMenu', () => { + let writeText: ReturnType + + beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + describe('item visibility', () => { + it('always shows Copy URL and Copy as cURL', () => { + renderMenu(makeRequest()) + expect(screen.getByText('Copy URL')).toBeInTheDocument() + expect(screen.getByText('Copy as cURL')).toBeInTheDocument() + }) + + it('shows Copy Request Body for POST requests', () => { + renderMenu(makeRequest({ method: 'POST' })) + expect(screen.getByText('Copy Request Body')).toBeInTheDocument() + }) + + it('hides Copy Request Body for GET requests without rawBody', () => { + renderMenu(makeRequest({ method: 'GET', rawBody: undefined })) + expect(screen.queryByText('Copy Request Body')).not.toBeInTheDocument() + }) + + it('shows Copy Request Body for GET requests when rawBody is present', () => { + renderMenu(makeRequest({ method: 'GET', rawBody: '{"query":"{ hero }"}' })) + expect(screen.getByText('Copy Request Body')).toBeInTheDocument() + }) + + it('shows Copy Response Body when response is present', () => { + renderMenu(makeRequest({ response: '{"data":{}}' })) + expect(screen.getByText('Copy Response Body')).toBeInTheDocument() + }) + + it('hides Copy Response Body when response is undefined', () => { + renderMenu(makeRequest({ response: undefined })) + expect(screen.queryByText('Copy Response Body')).not.toBeInTheDocument() + }) + + it('hides Copy Response Body when response is empty string', () => { + renderMenu(makeRequest({ response: '' })) + expect(screen.queryByText('Copy Response Body')).not.toBeInTheDocument() + }) + }) + + describe('copy actions', () => { + 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 Request Body copies constructed JSON for POST without rawBody', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ variables: undefined, extensions: undefined }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith( + JSON.stringify({ query: 'query GetHero { hero { name } }' }, null, 2) + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Request Body copies rawBody when present', async () => { + const onClose = vi.fn() + const rawBody = '[{"query":"{ hero }"}]' + renderMenu(makeRequest({ rawBody }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith(rawBody) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Request Body includes parsed variables in the constructed body', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ variables: '{"id":"1"}' }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith( + JSON.stringify( + { query: 'query GetHero { hero { name } }', variables: { id: '1' } }, + null, + 2 + ) + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Request Body includes parsed extensions in the constructed body', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ extensions: '{"persistedQuery":{"version":1}}' }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith( + JSON.stringify( + { + query: 'query GetHero { hero { name } }', + extensions: { persistedQuery: { version: 1 } }, + }, + null, + 2 + ) + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Request Body omits unparseable extensions from the constructed body', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ extensions: 'not-json' }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith( + JSON.stringify({ query: 'query GetHero { hero { name } }' }, null, 2) + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Request Body omits unparseable variables from the constructed body', async () => { + const onClose = vi.fn() + renderMenu(makeRequest({ variables: 'not-json' }), onClose) + fireEvent.click(screen.getByText('Copy Request Body')) + expect(writeText).toHaveBeenCalledWith( + JSON.stringify({ query: 'query GetHero { hero { name } }' }, null, 2) + ) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + it('Copy Response Body copies the response and calls onClose', async () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.click(screen.getByText('Copy Response Body')) + expect(writeText).toHaveBeenCalledWith('{"data":{"hero":{"name":"Luke"}}}') + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + + 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('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 URL')) + await vi.waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + }) + }) + + describe('dismiss behavior', () => { + it('pressing Escape calls onClose', () => { + const onClose = vi.fn() + renderMenu(makeRequest(), onClose) + fireEvent.keyDown(document, { key: 'Escape' }) + 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) + fireEvent.mouseDown(document.body, { button: 0 }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('left-clicking inside the menu does not call onClose', () => { + const onClose = vi.fn() + const { container } = renderMenu(makeRequest(), onClose) + const menu = container.querySelector('.gt-modal-actions-menu') as HTMLElement + fireEvent.mouseDown(menu, { button: 0 }) + expect(onClose).not.toHaveBeenCalled() + }) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx new file mode 100644 index 0000000..6ce009a --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestModal.test.tsx @@ -0,0 +1,424 @@ +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', () => ({})) +vi.mock('../ModalActionsMenu', () => ({ + ModalActionsMenu: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) +vi.mock('../RequestTab', () => ({ + RequestTab: ({ request }: { request: { query: string } }) => ( +
+ ), +})) +vi.mock('../ResponseTab', () => ({ + ResponseTab: ({ request }: { request: { response?: string } }) => ( +
+ ), +})) + +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 } }', + responseHeaders: [{ name: 'x-request-id', value: 'abc123' }], + ...overrides, + } +} + +function renderModal(req: GraphQLRequest = makeRequest(), onClose = vi.fn()) { + return render() +} + +function renderWithNav( + req = makeRequest(), + props: { onPrev?: () => void; onNext?: () => void } = {} +) { + 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() + }) + + 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() + 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('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) + 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() + }) + + 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() + }) + }) + + 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('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') + }) + }) + + describe('navigation buttons', () => { + it('renders Previous request and Next request buttons', () => { + renderWithNav() + expect(screen.getByRole('button', { name: 'Previous request' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next request' })).toBeInTheDocument() + }) + + it('Previous request button is disabled when onPrev is not provided', () => { + renderWithNav() + expect(screen.getByRole('button', { name: 'Previous request' })).toBeDisabled() + }) + + it('Next request button is disabled when onNext is not provided', () => { + renderWithNav() + expect(screen.getByRole('button', { name: 'Next request' })).toBeDisabled() + }) + + it('Previous request button is enabled when onPrev is provided', () => { + renderWithNav(makeRequest(), { onPrev: vi.fn() }) + expect(screen.getByRole('button', { name: 'Previous request' })).toBeEnabled() + }) + + it('Next request button is enabled when onNext is provided', () => { + renderWithNav(makeRequest(), { onNext: vi.fn() }) + expect(screen.getByRole('button', { name: 'Next request' })).toBeEnabled() + }) + + it('clicking Previous request button calls onPrev', () => { + const onPrev = vi.fn() + renderWithNav(makeRequest(), { onPrev }) + fireEvent.click(screen.getByRole('button', { name: 'Previous request' })) + expect(onPrev).toHaveBeenCalledOnce() + }) + + it('clicking Next request button calls onNext', () => { + const onNext = vi.fn() + renderWithNav(makeRequest(), { onNext }) + fireEvent.click(screen.getByRole('button', { name: 'Next request' })) + expect(onNext).toHaveBeenCalledOnce() + }) + + it('pressing ArrowLeft calls onPrev', () => { + const onPrev = vi.fn() + renderWithNav(makeRequest(), { onPrev }) + fireEvent.keyDown(document, { key: 'ArrowLeft' }) + expect(onPrev).toHaveBeenCalledOnce() + }) + + it('pressing ArrowRight calls onNext', () => { + const onNext = vi.fn() + renderWithNav(makeRequest(), { onNext }) + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect(onNext).toHaveBeenCalledOnce() + }) + + it('pressing ArrowLeft does nothing when onPrev is not provided', () => { + renderWithNav() + expect(() => fireEvent.keyDown(document, { key: 'ArrowLeft' })).not.toThrow() + }) + + it('pressing ArrowRight does nothing when onNext is not provided', () => { + renderWithNav() + expect(() => fireEvent.keyDown(document, { key: 'ArrowRight' })).not.toThrow() + }) + }) + + describe('actions menu', () => { + it('renders the More actions button', () => { + renderWithNav() + expect(screen.getByRole('button', { name: 'More actions' })).toBeInTheDocument() + }) + + it('clicking More actions button opens the actions menu', () => { + renderWithNav() + expect(screen.queryByTestId('modal-actions-menu')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'More actions' })) + expect(screen.getByTestId('modal-actions-menu')).toBeInTheDocument() + }) + + it('actions menu is closed after its onClose callback is invoked', () => { + renderWithNav() + fireEvent.click(screen.getByRole('button', { name: 'More actions' })) + expect(screen.getByTestId('modal-actions-menu')).toBeInTheDocument() + fireEvent.click(screen.getByText('Close menu')) + expect(screen.queryByTestId('modal-actions-menu')).not.toBeInTheDocument() + }) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx new file mode 100644 index 0000000..05d14d0 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestRow.test.tsx @@ -0,0 +1,164 @@ +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 type { GraphQLRequest } from '../har' +import { RequestRow } from '../RequestRow' + +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, + } +} + +const ariaAttributes = { 'aria-posinset': 1, 'aria-setsize': 1, role: 'listitem' as const } + +function renderRow(req: GraphQLRequest, onContextMenu = vi.fn(), onClick = vi.fn()) { + 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 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() + 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() + }) + + 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) + }) + + 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) + }) +}) diff --git a/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx new file mode 100644 index 0000000..a1cca97 --- /dev/null +++ b/entrypoints/devtools-panel/__tests__/RequestTab.test.tsx @@ -0,0 +1,325 @@ +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, theme }: { src: object; theme?: string }) => ( +
+ ), +})) + +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, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + describe('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() + 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('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) + }) + }) + + describe('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('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') + expect(jsonView).toBeInTheDocument() + expect(JSON.parse(jsonView.getAttribute('data-src')!)).toEqual({ id: '1' }) + }) + + it('uses monokai theme in dark mode', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + render() + expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') + }) + + 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('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)
+    })
+  })
+
+  describe('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('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')
+      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 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('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)
+    })
+  })
+
+  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/__tests__/ResponseTab.test.tsx b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
new file mode 100644
index 0000000..7f16963
--- /dev/null
+++ b/entrypoints/devtools-panel/__tests__/ResponseTab.test.tsx
@@ -0,0 +1,171 @@
+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, theme }: { src: object; theme?: string }) => (
+    
+ ), +})) + +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, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + describe('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() + }) + }) + + describe('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('uses monokai theme in dark mode', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + render() + expect(screen.getByTestId('json-view').getAttribute('data-theme')).toBe('monokai') + }) + + 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()
+    })
+  })
+
+  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 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)
+    })
+  })
+
+  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('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/__tests__/har.test.ts b/entrypoints/devtools-panel/__tests__/har.test.ts
new file mode 100644
index 0000000..8afa9b0
--- /dev/null
+++ b/entrypoints/devtools-panel/__tests__/har.test.ts
@@ -0,0 +1,1004 @@
+import { describe, it, expect } from 'vitest'
+
+import {
+  isGraphQLEntry,
+  extractOperationInfo,
+  extractQueryAndVariables,
+  extractBatchedOperations,
+  buildCurlCommand,
+} from '../har'
+import type { HAREntry, GraphQLRequest } 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,
+    getContent: () => {},
+    ...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('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({ query: 'query GetHero { hero { name } }' }) },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'GetHero',
+      operationType: 'query',
+    })
+  })
+
+  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({ query: 'mutation CreateUser { createUser { id } }' }) },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'CreateUser',
+      operationType: 'mutation',
+    })
+  })
+
+  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: 'subscription OnMessage { message { id } }' }) },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'OnMessage',
+      operationType: 'subscription',
+    })
+  })
+
+  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({ operationName: '  GetHero  ', query: 'query GetHero { hero }' }),
+        },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'GetHero',
+      operationType: 'query',
+    })
+  })
+
+  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({ operationName: '   ', query: 'query MyQuery { hero }' }),
+        },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'MyQuery',
+      operationType: 'query',
+    })
+  })
+
+  it('POST: anonymous mutation → capitalized type as name, mutation 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(extractOperationInfo(entry)).toEqual({
+      operationName: 'Mutation',
+      operationType: 'mutation',
+    })
+  })
+
+  it('POST: shorthand query → "Query" name, query type', () => {
+    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(extractOperationInfo(entry)).toEqual({ operationName: 'Query', operationType: 'query' })
+  })
+
+  it('POST: invalid query string → 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: '!@#invalid' }) },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('POST: no body → Anonymous, unknown', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('POST: invalid JSON → Anonymous, unknown', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'POST',
+        url: 'https://example.com/graphql',
+        headers: [{ name: 'content-type', value: 'application/json' }],
+        postData: { text: 'not-json' },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('POST: body has no query field → Anonymous, unknown', () => {
+    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(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('POST: fragment-only document → 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 } }' }) },
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('GET: named query param → name and type from AST', () => {
+    const query = encodeURIComponent('query GetHero { hero { name } }')
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: `https://example.com/graphql?query=${query}`,
+        headers: [],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'GetHero',
+      operationType: 'query',
+    })
+  })
+
+  it('GET: operationName param overrides AST name, type still from AST', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: 'https://example.com/graphql?query=%7B%20hero%20%7D&operationName=GetHero',
+        headers: [],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'GetHero',
+      operationType: 'query',
+    })
+  })
+
+  it('GET: shorthand query with no operationName → "Query", query type', () => {
+    const query = encodeURIComponent('{ hero }')
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: `https://example.com/graphql?query=${query}`,
+        headers: [],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({ operationName: 'Query', operationType: 'query' })
+  })
+
+  it('GET: valid URL with no query param → Anonymous, unknown', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: 'https://example.com/graphql?variables=%7B%7D',
+        headers: [],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+
+  it('GET: malformed URL → Anonymous, unknown', () => {
+    const entry = makeEntry({
+      request: {
+        method: 'GET',
+        url: 'not a url',
+        headers: [],
+      },
+    })
+    expect(extractOperationInfo(entry)).toEqual({
+      operationName: 'Anonymous',
+      operationType: 'unknown',
+    })
+  })
+})
+
+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: '' })
+  })
+
+  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', () => {
+  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('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({
+        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'")
+  })
+
+  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__/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/__tests__/useDevtoolsSettings.test.ts b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts
new file mode 100644
index 0000000..b80f185
--- /dev/null
+++ b/entrypoints/devtools-panel/__tests__/useDevtoolsSettings.test.ts
@@ -0,0 +1,96 @@
+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, DEFAULT_COLUMN_WIDTHS } 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', 'batch'])
+  })
+
+  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')
+  })
+
+  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/__tests__/useGraphQLRequests.test.ts b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts
new file mode 100644
index 0000000..d7448d9
--- /dev/null
+++ b/entrypoints/devtools-panel/__tests__/useGraphQLRequests.test.ts
@@ -0,0 +1,453 @@
+import { renderHook, act } from '@testing-library/react'
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+import type { HAREntry } from '../har'
+import { useGraphQLRequests } from '../useGraphQLRequests'
+
+type RequestListener = (entry: HAREntry) => void
+type NavigatedListener = () => void
+
+// 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>(),
+}))
+
+vi.mock('wxt/browser', () => ({
+  browser: {
+    devtools: {
+      network: {
+        onRequestFinished: {
+          addListener: onRequestFinishedAddListener,
+          removeListener: onRequestFinishedRemoveListener,
+        },
+        onNavigated: {
+          addListener: onNavigatedAddListener,
+          removeListener: onNavigatedRemoveListener,
+        },
+      },
+    },
+  },
+}))
+
+// Capture state reset per-test in beforeEach
+let capturedRequestListener: RequestListener | null = null
+let capturedNavigatedListeners: NavigatedListener[] = []
+
+function fire(entry: HAREntry) {
+  capturedRequestListener?.(entry)
+}
+
+function fireNavigated() {
+  capturedNavigatedListeners.forEach((l) => l())
+}
+
+function makeGraphQLEntry(
+  requestOverrides: Partial = {},
+  responseBody = '{"data":{"hero":{"name":"Luke"}}}',
+  encoding = '',
+  responseHeaders?: Array<{ name: string; value: string }>
+): 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 } }' }) },
+      ...requestOverrides,
+    },
+    response: { status: 200, content: { size: 512 }, headers: responseHeaders },
+    time: 123,
+    getContent: (cb) => cb(responseBody, encoding),
+  }
+}
+
+function makeNonGraphQLEntry(): HAREntry {
+  return {
+    request: {
+      method: 'GET',
+      url: 'https://api.example.com/rest',
+      headers: [],
+    },
+    response: { status: 200, content: { size: 100 } },
+    time: 50,
+    getContent: (cb) => cb('', ''),
+  }
+}
+
+describe('useGraphQLRequests', () => {
+  beforeEach(() => {
+    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', () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    expect(result.current.requests).toEqual([])
+  })
+
+  it('registers listener on mount', () => {
+    renderHook(() => useGraphQLRequests(false))
+    expect(onRequestFinishedAddListener).toHaveBeenCalledOnce()
+  })
+
+  it('removes same listener on unmount', () => {
+    const { unmount } = renderHook(() => useGraphQLRequests(false))
+    const registeredFn = onRequestFinishedAddListener.mock.calls[0][0]
+    unmount()
+    expect(onRequestFinishedRemoveListener).toHaveBeenCalledWith(registeredFn)
+  })
+
+  it('adds a GraphQLRequest entry when a matching HAR entry fires', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+    })
+    expect(result.current.requests).toHaveLength(1)
+    expect(result.current.requests[0]).toMatchObject({
+      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 } }',
+      response: '{"data":{"hero":{"name":"Luke"}}}',
+    })
+  })
+
+  it('stores variables when present in the request', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      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 () => {
+      fire(makeGraphQLEntry({}, ''))
+    })
+    expect(result.current.requests[0].response).toBeUndefined()
+  })
+
+  it('ignores non-GraphQL entries', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeNonGraphQLEntry())
+    })
+    expect(result.current.requests).toHaveLength(0)
+  })
+
+  it('multiple entries accumulate in order', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+      fire(
+        makeGraphQLEntry({
+          postData: {
+            text: JSON.stringify({ query: 'mutation CreateUser { createUser { id } }' }),
+          },
+        })
+      )
+    })
+    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', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+      fire(makeGraphQLEntry())
+      fire(makeGraphQLEntry())
+    })
+    expect(result.current.requests.map((r) => r.id)).toEqual(['1', '2', '3'])
+  })
+
+  it('clear() empties the request list', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+      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', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(true))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+    })
+    expect(result.current.requests).toHaveLength(1)
+    act(() => {
+      fireNavigated()
+    })
+    expect(result.current.requests).toHaveLength(0)
+  })
+
+  it('navigation event does NOT clear when autoClear is false', async () => {
+    const { result } = renderHook(() => useGraphQLRequests(false))
+    await act(async () => {
+      fire(makeGraphQLEntry())
+    })
+    expect(result.current.requests).toHaveLength(1)
+    act(() => {
+      fireNavigated()
+    })
+    expect(result.current.requests).toHaveLength(1)
+  })
+
+  it('onNavigated listener is removed on unmount when autoClear is true', () => {
+    const { unmount } = renderHook(() => useGraphQLRequests(true))
+    expect(onNavigatedAddListener).toHaveBeenCalledOnce()
+    const registeredFn = onNavigatedAddListener.mock.calls[0][0]
+    unmount()
+    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 () => {
+      fire(makeGraphQLEntry({}, btoa(json), 'base64'))
+    })
+    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 () => {
+      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 () => {
+      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!!!'
+    await act(async () => {
+      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 },
+    })
+    expect(onNavigatedAddListener).not.toHaveBeenCalled()
+    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
new file mode 100644
index 0000000..cbe0ddd
--- /dev/null
+++ b/entrypoints/devtools-panel/har.ts
@@ -0,0 +1,315 @@
+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 }
+    headers?: Array<{ name: string; value: string }>
+  }
+  time: number
+  getContent(callback: (content: string, encoding: string) => void): void
+}
+
+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
+  operationName: string
+  operationType: OperationType
+  status: number
+  size: number
+  time: number
+  url: string
+  method: string
+  headers: Array<{ name: string; value: string }>
+  query: string
+  variables?: string
+  extensions?: string
+  rawBody?: string
+  response?: string
+  responseHeaders?: Array<{ name: string; value: string }>
+  batchedOperations?: BatchedOperation[]
+}
+
+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)
+      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
+    }
+  }
+
+  if (method === 'GET') {
+    try {
+      return new URL(url).searchParams.has('query')
+    } catch {
+      return false
+    }
+  }
+
+  return false
+}
+
+export type QueryAndVariables = {
+  query: string
+  variables?: string
+  extensions?: 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
+        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
+    }
+  }
+
+  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
+          }
+        }
+        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
+    }
+  }
+
+  return { query: '' }
+}
+
+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.query === 'string') {
+        const info = parseOperation(body.query)
+        if (typeof body.operationName === 'string' && body.operationName.trim()) {
+          return { operationName: body.operationName.trim(), operationType: info.operationType }
+        }
+        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
+    }
+  }
+
+  if (method === 'GET') {
+    try {
+      const params = new URL(url).searchParams
+      const query = params.get('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 { 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)
+    const op = ast.definitions.find(
+      (d): d is OperationDefinitionNode => d.kind === 'OperationDefinition'
+    )
+    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 { 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') {
+    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
+        }
+      }
+      parts.push(`--data-raw ${shellEscape(JSON.stringify(body))}`)
+    }
+  }
+
+  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/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/tab-shared.css b/entrypoints/devtools-panel/tab-shared.css new file mode 100644 index 0000000..0264479 --- /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); + white-space: pre-wrap; + overflow-wrap: break-word; +} + +/* 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); +} 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 +} diff --git a/entrypoints/devtools-panel/useDevtoolsSettings.ts b/entrypoints/devtools-panel/useDevtoolsSettings.ts new file mode 100644 index 0000000..458c74c --- /dev/null +++ b/entrypoints/devtools-panel/useDevtoolsSettings.ts @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react' + +import { storage } from '#imports' + +import type { OperationType } from './har' + +export const FILTER_TYPES: OperationType[] = ['query', 'mutation', 'batch'] +export const DEFAULT_COLUMN_WIDTHS = [200, 100, 100, 100] + +const preserveLogItem = storage.defineItem('local:devtools.preserveLog', { + fallback: false, +}) +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) { + 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 + }) + } + + function setColumnWidths(widths: number[]) { + setColumnWidthsState(widths) + columnWidthsItem.setValue(widths) + } + + return { preserveLog, setPreserveLog, activeTypes, toggleType, columnWidths, setColumnWidths } +} diff --git a/entrypoints/devtools-panel/useGraphQLRequests.ts b/entrypoints/devtools-panel/useGraphQLRequests.ts new file mode 100644 index 0000000..693d691 --- /dev/null +++ b/entrypoints/devtools-panel/useGraphQLRequests.ts @@ -0,0 +1,81 @@ +import { useState, useEffect, useCallback } from 'react' +import { browser } from 'wxt/browser' + +import { + isGraphQLEntry, + extractOperationInfo, + extractQueryAndVariables, + extractBatchedOperations, +} from './har' +import type { HAREntry, GraphQLRequest } from './har' + +export function useGraphQLRequests(autoClear: boolean): { + requests: GraphQLRequest[] + clear: () => void +} { + const [requests, setRequests] = useState([]) + + const clear = useCallback(() => setRequests([]), []) + + useEffect(() => { + let counter = 0 + async function handleRequest(entry: HAREntry) { + if (!isGraphQLEntry(entry)) return + const { operationName, operationType } = extractOperationInfo(entry) + const { query, variables, extensions } = 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) + } + }) + }) + const batchedOperations = + operationType === 'batch' + ? extractBatchedOperations(entry, responseText || undefined) + : undefined + setRequests((prev) => [ + ...prev, + { + id: String(++counter), + operationName, + operationType, + status: entry.response.status, + size: entry.response.content.size, + time: entry.time, + url: entry.request.url, + method: entry.request.method, + headers: entry.request.headers, + query, + variables, + extensions, + rawBody: entry.request.postData?.text || undefined, + response: responseText || undefined, + responseHeaders: entry.response.headers, + batchedOperations, + }, + ]) + } + browser.devtools.network.onRequestFinished.addListener(handleRequest) + return () => { + browser.devtools.network.onRequestFinished.removeListener(handleRequest) + } + }, []) + + useEffect(() => { + if (!autoClear) return + function handleNavigated() { + setRequests([]) + } + browser.devtools.network.onNavigated.addListener(handleNavigated) + return () => browser.devtools.network.onNavigated.removeListener(handleNavigated) + }, [autoClear]) + + return { requests, clear } +} 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..b387118 100644 --- a/package.json +++ b/package.json @@ -27,16 +27,23 @@ "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": { "@graphiql/plugin-explorer": "^5.1.1", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", + "@microlink/react-json-view": "^1.31.1", + "filesize": "^11.0.13", "graphiql": "^5.2.2", + "graphql": "^16.13.1", "graphql-ws": "^6.0.7", + "highlight.js": "^11.11.1", + "pretty-ms": "^9.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-window": "^2.2.7", "uuid": "^13.0.0" }, "devDependencies": { @@ -44,6 +51,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..05951cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,25 +10,43 @@ 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.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.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.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.12.0))(graphql@16.12.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.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.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.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.1 + version: 16.13.1 graphql-ws: specifier: ^6.0.7 - version: 6.0.7(graphql@16.12.0) + version: 6.0.7(graphql@16.13.1) + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + pretty-ms: + specifier: ^9.3.0 + version: 9.3.0 react: specifier: ^19.2.4 version: 19.2.4 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 @@ -45,6 +63,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 @@ -666,6 +687,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@microlink/react-json-view@1.31.1': + resolution: {integrity: sha512-UsWwhSgNn06RDVrhnNejFypmu3xtfpxAzQ2BqXWY7NWs5FKZnjaNDFmB5kNpC1NDjzAH2v2oDq4JFEV3eLyDkw==} + 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'} @@ -1523,6 +1551,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==} @@ -1538,6 +1569,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==} @@ -1836,6 +1870,13 @@ packages: 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@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + commander@2.9.0: resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==} engines: {node: '>= 0.6.x'} @@ -2235,8 +2276,8 @@ packages: ws: optional: true - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + 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: @@ -2246,6 +2287,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==} @@ -2301,6 +2346,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'} @@ -2510,6 +2558,9 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2739,6 +2790,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 +2861,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==} @@ -2856,6 +2915,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + 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==} peerDependencies: @@ -2869,6 +2931,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'} @@ -2903,6 +2968,18 @@ 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: + 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'} @@ -3018,6 +3095,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==} @@ -3244,6 +3324,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'} @@ -3806,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.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.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.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.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.12.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) @@ -3820,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.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.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.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.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.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.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.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.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) @@ -3844,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.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.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.12.0))(graphql@16.12.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) @@ -3854,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.12.0 - graphql-language-service: 5.5.0(graphql@16.12.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.12.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) @@ -3875,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.12.0))(graphql@16.12.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.12.0 + graphql: 16.13.1 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.1) transitivePeerDependencies: - '@types/node' @@ -4010,6 +4117,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@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.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) + transitivePeerDependencies: + - '@types/react' + '@n1ru4l/push-pull-async-iterable-iterator@3.2.0': {} '@nodelib/fs.scandir@2.1.5': @@ -4672,6 +4789,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': {} @@ -4684,6 +4806,8 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/lodash@4.17.24': {} + '@types/minimatch@3.0.5': {} '@types/node@25.3.5': @@ -4993,6 +5117,16 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + commander@2.9.0: dependencies: graceful-readlink: 1.0.1 @@ -5318,18 +5452,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.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - graphql: 16.12.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.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.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.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.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) @@ -5342,23 +5476,25 @@ 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.1): dependencies: debounce-promise: 3.1.2 - graphql: 16.12.0 + graphql: 16.13.1 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.1): dependencies: - graphql: 16.12.0 + graphql: 16.13.1 - graphql@16.12.0: {} + graphql@16.13.1: {} growly@1.3.0: {} has-flag@4.0.0: {} + highlight.js@11.11.1: {} + hookable@6.0.1: {} html-encoding-sniffer@6.0.0: @@ -5412,6 +5548,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.4: {} + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -5617,6 +5755,8 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + lodash-es@4.17.23: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -5721,10 +5861,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.1)(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.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 @@ -5884,6 +6024,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 +6098,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: {} @@ -6009,6 +6155,13 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-base16-styling@0.10.0: + dependencies: + '@types/lodash': 4.17.24 + color: 4.2.3 + csstype: 3.2.3 + lodash-es: 4.17.23 + react-compiler-runtime@19.1.0-rc.1(react@19.2.4): dependencies: react: 19.2.4 @@ -6020,6 +6173,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): @@ -6049,6 +6204,20 @@ 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 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} readable-stream@2.3.8: @@ -6194,6 +6363,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: @@ -6411,6 +6584,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