From 9903fe19f2a81c7f4f050d9afd929c4af76aefc0 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 10 Oct 2026 23:17:34 +0000 Subject: [PATCH 1/2] feat(http): format, copy and mock the response preview The HTTP timeline preview showed JSON bodies on one long line and gave no way to reuse them. A JSON preview is now indented by 2 spaces, with numbers and strings copied as written. Other text and clipped bodies show as recorded. Only the redacted preview is formatted. Copy puts the shown preview on the clipboard and reports the result in the toolbar status. Mock this request fills in the fault rule form with the call's method, its path and query as the URL pattern (redacted values become *), and the preview as the body when it is complete JSON. Focus moves to the URL pattern and nothing is saved until Add rule. It uses the existing set-http-rules flow and rule shape. The rebuilt extension/ui bundle is included. --- app/src/__tests__/format.test.ts | 23 ++- .../network-response-preview.test.ts | 134 ++++++++++++++++++ app/src/format.ts | 51 +++++++ app/src/pages/network-inspector.ts | 121 ++++++++++++++-- apps/docs/src/content/guides/ssr-http.md | 2 +- apps/docs/src/content/inspectors/ssr-http.md | 4 +- ...=> browser-agent-rpc-BXhoSh1z-BeppHGOP.js} | 2 +- .../{index-BEfrZ0QX.js => index-BELKFkIj.js} | 73 ++++++---- extension/ui/index.html | 2 +- 9 files changed, 365 insertions(+), 47 deletions(-) create mode 100644 app/src/__tests__/network-response-preview.test.ts rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-C_twCiPD.js => browser-agent-rpc-BXhoSh1z-BeppHGOP.js} (93%) rename extension/ui/assets/{index-BEfrZ0QX.js => index-BELKFkIj.js} (87%) diff --git a/app/src/__tests__/format.test.ts b/app/src/__tests__/format.test.ts index 8cd091a0..c2f6635c 100644 --- a/app/src/__tests__/format.test.ts +++ b/app/src/__tests__/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { time } from '../format'; +import { prettyJson, time } from '../format'; describe('time', () => { it('formats a timestamp as the local time of day', () => { @@ -7,3 +7,24 @@ describe('time', () => { expect(time(at)).toBe(new Date(at).toLocaleTimeString()); }); }); + +describe('prettyJson', () => { + it('indents objects and arrays by 2 spaces, like JSON.stringify', () => { + const value = { a: 1, b: [true, null, { c: 'x' }], d: {}, e: [], f: 'p:q,{r}"s"' }; + expect(prettyJson(JSON.stringify(value))).toBe(JSON.stringify(value, null, 2)); + }); + + it('keeps numbers and escapes as written', () => { + expect(prettyJson('{"id":12345678901234567890,"s":"a\\"b\\\\","n":1.50}')).toBe( + '{\n "id": 12345678901234567890,\n "s": "a\\"b\\\\",\n "n": 1.50\n}', + ); + }); + + it('returns null for text, clipped JSON and bare values', () => { + expect(prettyJson('hello')).toBeNull(); + expect(prettyJson('{"a":[1,2')).toBeNull(); + expect(prettyJson('{"a":1}…')).toBeNull(); + expect(prettyJson('42')).toBeNull(); + expect(prettyJson('"text"')).toBeNull(); + }); +}); diff --git a/app/src/__tests__/network-response-preview.test.ts b/app/src/__tests__/network-response-preview.test.ts new file mode 100644 index 00000000..450339cd --- /dev/null +++ b/app/src/__tests__/network-response-preview.test.ts @@ -0,0 +1,134 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { NetworkInspector } from '../pages/network-inspector'; + +let fixture: ComponentFixture; + +const call = (overrides: Record) => ({ + id: 'c1', + url: '/api/products?page=2', + method: 'GET', + status: 200, + durationMs: 12, + side: 'client' as const, + cacheHit: false, + faulted: false, + at: 1000, + ...overrides, +}); + +async function open(overrides: Record) { + fixture = TestBed.createComponent(NetworkInspector); + document.body.append(fixture.nativeElement); + const inspector = fixture.componentInstance; + inspector.pages.set([ + { + pageId: 'p1', + url: '/products', + title: 'Products', + hydration: null, + reportedAt: 1000, + calls: [call(overrides)], + }, + ]); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + host.querySelector('button[data-call-id="c1"]')!.click(); + await fixture.whenStable(); + const preview = host.querySelector('#call-preview')!; + const button = (name: string) => + [...preview.querySelectorAll('button')].find( + (b) => b.textContent!.trim() === name, + ); + const status = () => host.querySelector('.toolbar [role="status"]')!.textContent!.trim(); + return { host, preview, button, status, inspector }; +} + +describe('NetworkInspector response preview', () => { + beforeAll(() => { + Element.prototype.scrollIntoView ??= () => {}; + }); + + afterEach(() => { + fixture?.destroy(); + TestBed.resetTestingModule(); + }); + + it('shows a JSON preview indented by 2 spaces', async () => { + const { preview } = await open({ preview: '{"items":[{"id":1,"token":"[redacted]"}]}' }); + expect(preview.querySelector('pre')!.textContent).toBe( + '{\n "items": [\n {\n "id": 1,\n "token": "[redacted]"\n }\n ]\n}', + ); + }); + + it('shows other text as recorded', async () => { + const clipped = '{"items":[1,2,3…'; + const { preview } = await open({ preview: clipped }); + expect(preview.querySelector('pre')!.textContent).toBe(clipped); + }); + + it('copies the shown preview and says so in a polite status', async () => { + const written: string[] = []; + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: (text: string) => (written.push(text), Promise.resolve()) }, + }); + const { button, status, inspector } = await open({ preview: '{"a":1}' }); + const copy = button('Copy')!; + expect(copy.getAttribute('aria-label')).toBe('Copy the response of GET /api/products?page=2'); + await inspector.copyPreview(inspector.selectedCall()!); + await fixture.whenStable(); + expect(written).toEqual(['{\n "a": 1\n}']); + expect(status()).toBe('Copied.'); + }); + + it('says when the clipboard is not available', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: () => Promise.reject(new Error('denied')) }, + }); + const { status, inspector } = await open({ preview: 'plain text' }); + await inspector.copyPreview(inspector.selectedCall()!); + await fixture.whenStable(); + expect(status()).toBe('The clipboard is not available here.'); + }); + + it('has no Copy button without a body', async () => { + const { button } = await open({ status: 0, error: 'Http failure' }); + expect(button('Copy')).toBeUndefined(); + expect(button('Mock this request')).toBeDefined(); + }); + + it('fills the rule form with the method, URL and JSON body, then focuses it', async () => { + const { host, button, status, inspector } = await open({ + url: 'http://localhost:4000/api/products?key=[redacted]', + method: 'get', + side: 'server', + preview: '{"items":[]}', + }); + button('Mock this request')!.click(); + await fixture.whenStable(); + expect(inspector.draft()).toMatchObject({ + pattern: '/api/products?key=*', + method: 'GET', + body: '{\n "items": []\n}', + status: '', + }); + expect(inspector.draftRule()).toMatchObject({ pattern: '/api/products?key=*', method: 'GET' }); + const pattern = host.querySelector('#rule-pattern')!; + expect(document.activeElement).toBe(pattern); + expect(pattern.value).toBe('/api/products?key=*'); + expect(host.querySelector('textarea')!.value).toBe('{\n "items": []\n}'); + expect(status()).toContain('with the preview as its body'); + }); + + it('fills only the method and URL when the preview is not JSON', async () => { + const { button, status, inspector } = await open({ method: 'HEAD', preview: 'hello' }); + button('Mock this request')!.click(); + await fixture.whenStable(); + expect(inspector.draft()).toMatchObject({ pattern: '/api/products?page=2', method: 'HEAD' }); + expect(inspector.draft().body).toBe(''); + expect(inspector.methodOptions().map((o) => o.value)).toContain('HEAD'); + expect(status()).toContain('not complete JSON'); + }); +}); diff --git a/app/src/format.ts b/app/src/format.ts index c893068c..322adc3a 100644 --- a/app/src/format.ts +++ b/app/src/format.ts @@ -1,3 +1,54 @@ export function time(timestamp: number): string { return new Date(timestamp).toLocaleTimeString(); } + +const JSON_SPACE = /[ \t\n\r]/; + +/** + * Indents a JSON object or array by 2 spaces. Returns null for anything else, + * including clipped or invalid JSON. Numbers and strings are copied as written, + * so large numbers keep every digit. + */ +export function prettyJson(text: string): string | null { + const source = text.trim(); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + return null; + } + if (parsed === null || typeof parsed !== 'object') return null; + let out = ''; + let depth = 0; + const newline = () => `\n${' '.repeat(depth)}`; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + if (ch === '"') { + let end = i + 1; + while (source[end] !== '"') end += source[end] === '\\' ? 2 : 1; + out += source.slice(i, end + 1); + i = end; + } else if (ch === '{' || ch === '[') { + const close = ch === '{' ? '}' : ']'; + let next = i + 1; + while (JSON_SPACE.test(source[next])) next++; + if (source[next] === close) { + out += ch + close; + i = next; + } else { + depth++; + out += ch + newline(); + } + } else if (ch === '}' || ch === ']') { + depth--; + out += newline() + ch; + } else if (ch === ',') { + out += `,${newline()}`; + } else if (ch === ':') { + out += ': '; + } else if (!JSON_SPACE.test(ch)) { + out += ch; + } + } + return out; +} diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index 5ab02601..777a56a3 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -22,6 +22,7 @@ import { isHttpRuleStatus, type CacheSkip, } from '@pangular-inspector/devtools/config'; +import { prettyJson } from '../format'; import { LimitNote } from '../ui/limit-note'; import { Select, type SelectOption } from '../ui/select'; @@ -217,6 +218,31 @@ function pathOf(url: string): string { } } +const REDACTED_IN_URL = /\[redacted\]|%5Bredacted%5D/gi; + +/** + * A rule pattern for a recorded call: its path and query, without the origin, so it + * matches the relative client URL and the absolute SSR URL. A redacted value becomes + * `*`, because the real request carries the secret. + */ +function mockPattern(url: string): string { + return pathOf(url).replace(REDACTED_IN_URL, '*'); +} + +/** The preview as a mock body, when it is complete JSON (the rule form takes JSON only). */ +function jsonBody(preview: string): string | null { + const pretty = prettyJson(preview); + if (pretty !== null) return pretty; + try { + JSON.parse(preview); + return preview.trim(); + } catch { + return null; + } +} + +const STANDARD_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; + const MAX_RULES = 50; const MAX_PATTERN = 500; const MAX_BODY = 100_000; @@ -577,7 +603,29 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ >

Response preview

- +
+ @if (!detail.error && detail.preview) { + + } + + +

{{ detail.method }} @@ -595,9 +643,11 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ Matched rule {{ pattern }}

} -
{{
-              detail.error ?? detail.preview ?? '(no body)'
-            }}
+
{{ detail.error ?? previewText(detail) ?? '(no body)' }}
} @@ -607,7 +657,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ @if (!canWrite()) {

{{ writesOff }}

} -
+