Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion app/src/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
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', () => {
const at = new Date(2026, 0, 2, 13, 4, 5).getTime();
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();
});
});
134 changes: 134 additions & 0 deletions app/src/__tests__/network-response-preview.test.ts
Original file line number Diff line number Diff line change
@@ -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<NetworkInspector>;

const call = (overrides: Record<string, unknown>) => ({
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<string, unknown>) {
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<HTMLButtonElement>('button[data-call-id="c1"]')!.click();
await fixture.whenStable();
const preview = host.querySelector<HTMLElement>('#call-preview')!;
const button = (name: string) =>
[...preview.querySelectorAll<HTMLButtonElement>('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<HTMLInputElement>('#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');
});
});
51 changes: 51 additions & 0 deletions app/src/format.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading