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
162 changes: 162 additions & 0 deletions app/src/__tests__/network-timeline-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { NetworkInspector } from '../pages/network-inspector';

let fixture: ComponentFixture<NetworkInspector>;

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

const host = () => fixture.nativeElement as HTMLElement;
const rows = () => [...host().querySelectorAll<HTMLElement>('button[data-call-id]')];
const rowIds = () => rows().map((b) => b.dataset['callId']);
const total = () => host().querySelector('.filters .total')!.textContent!.trim();
const search = () => host().querySelector<HTMLInputElement>('#timeline-search')!;

async function type(value: string) {
search().value = value;
search().dispatchEvent(new Event('input'));
await fixture.whenStable();
}

describe('NetworkInspector timeline filters', () => {
beforeEach(async () => {
fixture = TestBed.createComponent(NetworkInspector);
const inspector = fixture.componentInstance;
inspector.serverCalls.set([
call({ id: 's1', side: 'server', url: 'http://localhost/api/products', at: 1001 }),
call({ id: 's2', side: 'server', url: 'http://localhost/api/cart', status: 503, at: 1002 }),
]);
inspector.pages.set([
{
pageId: 'p1',
url: '/shop',
title: 'Shop',
hydration: null,
reportedAt: 1100,
calls: [
call({ id: 'c1', url: '/api/products', at: 1003 }),
call({ id: 'c2', method: 'POST', url: '/api/orders', status: 0, at: 1004 }),
call({ id: 'c3', url: '/api/search', status: 0, cancelled: true, at: 1005 }),
call({ id: 'c4', url: '/api/user', status: 304, at: 1006 }),
],
},
]);
await fixture.whenStable();
});

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

it('labels every filter control and counts all calls', () => {
expect(rows()).toHaveLength(6);
expect(total()).toBe('6 of 6');
expect(search().getAttribute('aria-label')).toBe('Find a URL or method');
const failed = [...host().querySelectorAll<HTMLLabelElement>('.filters label')].find((l) =>
l.textContent?.includes('Failed only'),
);
expect(failed?.querySelector('input[type="checkbox"]')).not.toBeNull();
});

it('matches the URL and the method, and Escape clears the search', async () => {
await type('products');
expect(rowIds()).toEqual(['c1', 's1']);
expect(total()).toBe('2 of 6');

await type('post');
expect(rowIds()).toEqual(['c2']);
expect(total()).toBe('1 of 6');

// A method and path query also finds server calls whose URL is absolute.
await type('get /api/cart');
expect(rowIds()).toEqual(['s2']);

search().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await fixture.whenStable();
expect(search().value).toBe('');
expect(rows()).toHaveLength(6);
expect(total()).toBe('6 of 6');
});

it('keeps failed, ERR and cancelled calls with Failed only', async () => {
const box = host().querySelector<HTMLInputElement>('.failed-toggle input')!;
box.click();
await fixture.whenStable();
expect(rowIds()).toEqual(['c3', 'c2', 's2']);
expect(total()).toBe('3 of 6');
});

it('filters by side and combines filters', async () => {
fixture.componentInstance.sideFilter.set('server');
await fixture.whenStable();
expect(rowIds()).toEqual(['s2', 's1']);
expect(total()).toBe('2 of 6');

fixture.componentInstance.failedOnly.set(true);
await fixture.whenStable();
expect(rowIds()).toEqual(['s2']);

fixture.componentInstance.sideFilter.set('client');
await fixture.whenStable();
expect(rowIds()).toEqual(['c3', 'c2']);
});

it('shows a no-match state whose Clear filters resets every filter', async () => {
fixture.componentInstance.sideFilter.set('server');
fixture.componentInstance.failedOnly.set(true);
await type('nothing-here');
expect(rows()).toHaveLength(0);
expect(total()).toBe('0 of 6');
expect(host().textContent).toContain('No requests match.');
expect(host().textContent).not.toContain('No requests yet.');

const clear = [...host().querySelectorAll('button')].find(
(b) => b.textContent?.trim() === 'Clear filters',
)!;
clear.click();
await fixture.whenStable();
expect(rows()).toHaveLength(6);
expect(search().value).toBe('');
expect(fixture.componentInstance.sideFilter()).toBe('all');
expect(fixture.componentInstance.failedOnly()).toBe(false);
expect(document.activeElement).toBe(search());
});

it('hides the preview while its call is filtered out and brings it back after', async () => {
rows()
.find((b) => b.dataset['callId'] === 'c1')!
.closest('tr')!
.click();
await fixture.whenStable();
expect(host().querySelector('#call-preview')).not.toBeNull();

await type('orders');
expect(host().querySelector('#call-preview')).toBeNull();
expect(host().querySelector('tr.selected')).toBeNull();

await type('');
expect(host().querySelector('#call-preview')).not.toBeNull();
expect(host().querySelector('tr.selected button')?.getAttribute('data-call-id')).toBe('c1');
});

it('keeps the empty state when there are no calls at all', async () => {
fixture.componentInstance.serverCalls.set([]);
fixture.componentInstance.pages.set([]);
await fixture.whenStable();
expect(total()).toBe('0 of 0');
expect(host().textContent).toContain('No requests yet.');
expect(host().textContent).not.toContain('Clear filters');
});
});
145 changes: 142 additions & 3 deletions app/src/pages/network-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,43 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
what="HTTP calls"
limit="httpCalls"
/>
@if (timeline().length) {
<div class="filters" role="group" aria-label="Filter the HTTP timeline">
<div class="search">
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
id="timeline-search"
type="search"
placeholder="Find a URL or method…"
aria-label="Find a URL or method"
autocomplete="off"
spellcheck="false"
[value]="callQuery()"
(input)="callQuery.set($any($event.target).value)"
(keydown.escape)="callQuery.set('')"
/>
</div>
<app-select
class="side-filter"
ariaLabel="Side"
[options]="sideOptions"
[(value)]="sideFilter"
/>
<label class="inline failed-toggle">
<input
type="checkbox"
[checked]="failedOnly()"
(change)="failedOnly.set($any($event.target).checked)"
/>
Failed only
</label>
<span class="total" aria-live="polite"
>{{ visibleCalls().length }} of {{ timeline().length }}</span
>
</div>
@if (visibleCalls().length) {
<div class="table-wrap">
<table>
<thead>
Expand All @@ -497,7 +533,7 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
</tr>
</thead>
<tbody>
@for (entry of timeline(); track entry.side + entry.id) {
@for (entry of visibleCalls(); track entry.side + entry.id) {
<tr
[class.selected]="selectedCall()?.id === entry.id"
(click)="openCall(entry.id)"
Expand Down Expand Up @@ -558,6 +594,20 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
</tbody>
</table>
</div>
} @else if (timeline().length) {
<div class="empty no-match" role="status">
<p>No requests match.</p>
<p class="muted small">
@if (callQuery().trim()) {
Nothing matches “{{ callQuery().trim() }}” with these filters.
} @else {
None of the {{ timeline().length }} requests fit these filters.
}
</p>
<div class="empty-actions">
<button type="button" (click)="clearCallFilters()">Clear filters</button>
</div>
</div>
} @else {
<div class="empty">
<p>No requests yet.</p>
Expand Down Expand Up @@ -1315,6 +1365,59 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
text-align: center;
line-height: 1.5;
}
.empty-actions {
display: flex;
justify-content: center;
margin-top: 8px;
}
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin: 0 0 12px;
}
.search {
position: relative;
flex: 1 1 220px;
min-width: 0;
}
.search-icon {
position: absolute;
top: 50%;
left: 12px;
width: 14px;
height: 14px;
transform: translateY(-50%);
fill: none;
stroke: var(--text-3);
stroke-width: 2.2;
stroke-linecap: round;
pointer-events: none;
}
.search:focus-within .search-icon {
stroke: var(--accent);
}
.filters input[type='search'] {
height: var(--control-h);
padding-left: 34px;
}
.filters .side-filter {
flex: 0 0 140px;
}
.failed-toggle {
flex: none;
height: var(--control-h);
font-size: 13px;
font-weight: 500;
}
.total {
flex: none;
color: var(--text-2);
font-size: 12px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.table-wrap {
max-height: min(420px, 60vh);
overflow: auto;
Expand Down Expand Up @@ -1878,8 +1981,32 @@ export class NetworkInspector {
[...this.pageServerCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at),
);

readonly callQuery = signal('');
readonly sideFilter = signal<HttpSide | 'all' | null>('all');
readonly failedOnly = signal(false);
readonly sideOptions: SelectOption<HttpSide | 'all'>[] = [
{ value: 'all', label: 'All sides' },
{ value: 'server', label: 'SSR' },
{ value: 'client', label: 'Client' },
];

readonly visibleCalls = computed(() => {
const query = this.callQuery().trim().toLowerCase();
const side = this.sideFilter() ?? 'all';
const failedOnly = this.failedOnly();
return this.timeline().filter(
(c) =>
(side === 'all' || c.side === side) &&
(!failedOnly || this.isFailed(c)) &&
(!query ||
`${c.method} ${c.url}`.toLowerCase().includes(query) ||
`${c.method} ${pathOf(c.url)}`.toLowerCase().includes(query)),
);
});

/** Follows the visible rows, so the preview hides while filters hide its call. */
readonly selectedCall = computed(
() => this.timeline().find((c) => c.id === this.selectedCallId()) ?? null,
() => this.visibleCalls().find((c) => c.id === this.selectedCallId()) ?? null,
);

readonly selectedRequest = computed(
Expand Down Expand Up @@ -2049,6 +2176,18 @@ export class NetworkInspector {
return call.cancelled ? 'neutral' : this.statusTone(call.status);
}

/** A failed call has a status of 400 or more, no status (ERR) or was cancelled. */
isFailed(call: HttpCall): boolean {
return !!call.cancelled || !call.status || call.status >= 400;
}

clearCallFilters() {
this.callQuery.set('');
this.sideFilter.set('all');
this.failedOnly.set(false);
this.host.nativeElement.querySelector<HTMLInputElement>('#timeline-search')?.focus();
}

rulePatternOf(call: HttpCall): string | null {
if (call.rulePattern) return call.rulePattern;
if (!call.ruleId) return null;
Expand Down
10 changes: 10 additions & 0 deletions apps/docs/src/content/inspectors/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ Every `HttpClient` request, tagged **SSR** or **Client**. Each row shows the met

A request that was unsubscribed before its response, for example by `switchMap`, a route change or a destroyed component, shows **cancelled** as its status. **ERR** marks a request that failed without a status, such as when the browser is offline or CORS blocks it.

Above the table, these filters narrow the timeline. They combine, and the count next to them shows how many calls are visible, for example **12 of 200**.

| Filter | What it keeps |
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
| **Find a URL or method** | Calls whose method or URL contains the text, ignoring case. `post /api` matches both. `Escape` clears it. |
| **Side** | **All sides**, **SSR** or **Client** calls. |
| **Failed only** | Calls with a status of 400 or more, **ERR** or **cancelled**. |

If nothing matches, **Clear filters** resets all three. While the filters hide the call that is open in the preview, the preview hides too, and it comes back when the call is visible again.

Click a row for a response preview. The preview opens under the timeline and takes focus. **Close** or `Escape` returns focus to the row. A long timeline scrolls inside its own box. The timeline shows the page's client calls and the SSR calls made while rendering its first URL. **Clear timeline** empties it.

### Fault injection
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading