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 4b955d11..a109ef10 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; @@ -627,7 +653,29 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ >

Response preview

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

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

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

{{ writesOff }}

} -
+