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
89 changes: 89 additions & 0 deletions app/src/__tests__/network-call-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 written: string[] = [];

const host = () => fixture.nativeElement as HTMLElement;
const button = (name: string) =>
[...host().querySelectorAll<HTMLButtonElement>('#call-preview button')].find(
(b) => b.textContent!.trim() === name,
)!;
const status = () => host().querySelector('.toolbar .message')!.textContent!.trim();

async function openCall() {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
fixture.componentInstance.pages.set([
{
pageId: 'p1',
url: '/shop',
title: 'Shop',
hydration: null,
reportedAt: 1,
calls: [
{
id: 'c1',
url: '/api/cart',
method: 'POST',
status: 201,
durationMs: 5,
side: 'client',
cacheHit: false,
faulted: false,
at: 1,
requestHeaders: { authorization: '[redacted]', 'content-type': 'application/json' },
},
],
},
]);
await fixture.whenStable();
fixture.componentInstance.openCall('c1');
await fixture.whenStable();
}

describe('NetworkInspector copy as fetch and cURL', () => {
beforeAll(() => {
Element.prototype.scrollIntoView ??= () => {};
});

afterEach(() => {
written.length = 0;
fixture?.destroy();
TestBed.resetTestingModule();
});

it('copies the masked call as a curl command and as fetch', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: (text: string) => (written.push(text), Promise.resolve()) },
});
await openCall();
const curl = button('Copy as cURL');
expect(curl.getAttribute('aria-label')).toBe('Copy POST /api/cart as cURL');
curl.click();
await fixture.whenStable();
expect(written[0]).toContain("-X 'POST'");
expect(written[0]).toContain("-H 'authorization: [redacted]'");
expect(status()).toBe('Copied as cURL. Fill in the masked values first.');

button('Copy as fetch').click();
await fixture.whenStable();
expect(written[1]).toContain('await fetch(');
expect(written[1]).toContain('"method": "POST"');
});

it('says when the clipboard is not available', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => Promise.reject(new Error('denied')) },
});
await openCall();
button('Copy as fetch').click();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
expect(status()).toBe('The clipboard is not available here.');
});
});
53 changes: 53 additions & 0 deletions app/src/__tests__/network-call-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 = (id: string, origin?: 'httpResource' | 'resource' | 'defer') => ({
id,
url: `/api/${id}`,
method: 'GET',
status: 200,
durationMs: 5,
side: 'client' as const,
cacheHit: false,
faulted: false,
at: 1,
...(origin ? { origin } : {}),
});

describe('NetworkInspector call origin', () => {
beforeAll(() => {
Element.prototype.scrollIntoView ??= () => {};
});

afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
});

it('tags calls with the API that likely started them', async () => {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
fixture.componentInstance.pages.set([
{
pageId: 'p1',
url: '/',
title: 'Home',
hydration: null,
reportedAt: 1,
calls: [call('a', 'httpResource'), call('b', 'resource'), call('c', 'defer'), call('d')],
},
]);
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const tags = [...host.querySelectorAll('app-call-origin .tag')];
expect(tags.map((t) => t.textContent!.trim()).sort()).toEqual([
'likely @defer',
'likely httpResource()',
'likely resource()',
]);
expect(tags[0].getAttribute('title')).toContain('guessed from the call stack');
});
});
118 changes: 118 additions & 0 deletions app/src/__tests__/network-har-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { NetworkInspector } from '../pages/network-inspector';
import { harFileName } from '../pages/network/har-export';

let fixture: ComponentFixture<NetworkInspector>;

const call = (overrides: Record<string, unknown>) => ({
id: 'c',
url: '/api/products',
method: 'GET',
status: 200,
durationMs: 10,
side: 'client' as const,
cacheHit: false,
faulted: false,
at: 1000,
pageUrl: '/shop',
...overrides,
});

const host = () => fixture.nativeElement as HTMLElement;
const exportButton = () =>
[...host().querySelectorAll<HTMLButtonElement>('app-har-export button')].find(
(b) => b.textContent!.trim() === 'Export HAR',
)!;

describe('harFileName', () => {
it('names the file after the page path', () => {
expect(harFileName('/shop/7?tab=reviews')).toBe('pangular-shop-7.har');
expect(harFileName('/')).toBe('pangular-home.har');
expect(harFileName(undefined)).toBe('pangular-home.har');
});
});

describe('NetworkInspector HAR export', () => {
beforeAll(() => {
Element.prototype.scrollIntoView ??= () => {};
});

afterEach(() => {
vi.restoreAllMocks();
fixture?.destroy();
TestBed.resetTestingModule();
});

it('is off until the timeline has a call', async () => {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
await fixture.whenStable();
expect(exportButton().disabled).toBe(true);
});

it('saves the timeline and the SSR request as a HAR file', async () => {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
const inspector = fixture.componentInstance;
inspector.requests.set([
{
id: 'r1',
method: 'GET',
url: '/shop',
status: 200,
at: 500,
durationMs: 80,
renderMs: 60,
bytes: 100,
renderMode: 'server',
fetches: 1,
fetchMs: 5,
headers: { 'server-timing': 'render;dur=60' },
},
]);
inspector.serverCalls.set([call({ id: 's1', side: 'server', requestId: 'r1', at: 600 })]);
inspector.pages.set([
{
pageId: 'p1',
url: '/shop',
title: 'Shop',
hydration: null,
ssrRequestId: 'r1',
reportedAt: 1,
calls: [call({ id: 'c1', requestHeaders: { authorization: '[redacted]' } })],
},
]);
await fixture.whenStable();

let blob: Blob | undefined;
const create = vi.fn((value: Blob) => {
blob = value;
return 'blob:har';
});
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() });
let downloaded = '';
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
downloaded = this.download;
});

exportButton().click();
await fixture.whenStable();

expect(downloaded).toBe('pangular-shop.har');
const har = JSON.parse(await blob!.text());
expect(har.log.entries.map((e: { _side: string }) => e._side)).toEqual([
'document',
'server',
'client',
]);
expect(har.log.entries[2].request.headers).toEqual([
{ name: 'authorization', value: '[redacted]' },
]);
expect(host().querySelector('.toolbar .message')!.textContent!.trim()).toBe(
'Saved 3 requests as pangular-shop.har.',
);
});
});
101 changes: 101 additions & 0 deletions app/src/__tests__/network-http-lint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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 = (id: string, at: number, overrides: Record<string, unknown> = {}) => ({
id,
url: `/api/${id}`,
method: 'GET',
status: 200,
durationMs: 100,
side: 'client' as const,
cacheHit: false,
faulted: false,
at,
pageUrl: '/shop',
...overrides,
});

const host = () => fixture.nativeElement as HTMLElement;
const lint = () => host().querySelector('app-http-lint')!;

async function mount(
calls: ReturnType<typeof call>[],
serverCalls: ReturnType<typeof call>[] = [],
) {
fixture = TestBed.createComponent(NetworkInspector);
document.body.append(fixture.nativeElement);
const inspector = fixture.componentInstance;
inspector.serverCalls.set(serverCalls);
inspector.pages.set([
{
pageId: 'p1',
url: '/shop',
title: 'Shop',
hydration: null,
ssrRequestId: 'r1',
reportedAt: 1,
calls,
},
]);
await fixture.whenStable();
return inspector;
}

describe('NetworkInspector HTTP lint', () => {
beforeAll(() => {
Element.prototype.scrollIntoView ??= () => {};
});

afterEach(() => {
fixture?.destroy();
TestBed.resetTestingModule();
});

it('has its own section with an h2', async () => {
await mount([]);
const heading = host().querySelector('#lint-heading')!;
expect(heading.tagName).toBe('H2');
expect(heading.textContent!.trim()).toBe('HTTP lint');
expect(lint().querySelector('.none')!.textContent!.trim()).toBe(
'No client calls to check yet.',
);
});

it('says when nothing was found', async () => {
await mount([call('a', 0)]);
expect(lint().querySelector('.none.ok')!.textContent!.trim()).toBe(
'No duplicate requests, waterfalls or repeated server calls.',
);
});

it('lists duplicates, waterfalls and repeated server calls, and opens the first call', async () => {
const inspector = await mount(
[
call('a', 0, { url: '/api/user' }),
call('b', 300, { url: '/api/user' }),
call('c', 5000),
call('d', 5110),
call('e', 5220),
call('f', 9000, { url: '/api/p' }),
],
[call('s1', 0, { side: 'server', url: 'http://api/api/p', requestId: 'r1' })],
);
const items = [...lint().querySelectorAll('.findings li')];
expect(items.map((li) => li.querySelector('.kind')!.textContent!.trim())).toEqual([
'Duplicate',
'Waterfall',
'Repeated server call',
]);
expect(items[0].querySelector('.message')!.textContent).toContain(
'GET /api/user ran 2 times within 300 ms.',
);
const show = items[1].querySelector('button')!;
expect(show.getAttribute('aria-label')).toContain('Show the first call of: 3 calls ran');
show.click();
await fixture.whenStable();
expect(inspector.selectedCall()?.id).toBe('c');
});
});
Loading
Loading