Skip to content

Commit 8d102ea

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/signals-injectors
# Conflicts: # extension/ui/assets/browser-agent-rpc-KH4HHvHf-BHMd_NN4.js # extension/ui/assets/browser-agent-rpc-KH4HHvHf-DtH28z9R.js # extension/ui/assets/browser-agent-rpc-KH4HHvHf-bRoSxJvJ.js # extension/ui/assets/index-B4D7r6Ti.js # extension/ui/assets/index-BUvoD20I.js # extension/ui/assets/index-CQR2JOj6.js # extension/ui/index.html # packages/devtools/package.json
2 parents 056da73 + 5033639 commit 8d102ea

121 files changed

Lines changed: 13053 additions & 2018 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
3+
import { NetworkInspector } from '../pages/network-inspector';
4+
5+
let fixture: ComponentFixture<NetworkInspector>;
6+
const written: string[] = [];
7+
8+
const host = () => fixture.nativeElement as HTMLElement;
9+
const button = (name: string) =>
10+
[...host().querySelectorAll<HTMLButtonElement>('#call-preview button')].find(
11+
(b) => b.textContent!.trim() === name,
12+
)!;
13+
const status = () => host().querySelector('.toolbar .message')!.textContent!.trim();
14+
15+
async function openCall() {
16+
fixture = TestBed.createComponent(NetworkInspector);
17+
document.body.append(fixture.nativeElement);
18+
fixture.componentInstance.pages.set([
19+
{
20+
pageId: 'p1',
21+
url: '/shop',
22+
title: 'Shop',
23+
hydration: null,
24+
reportedAt: 1,
25+
calls: [
26+
{
27+
id: 'c1',
28+
url: '/api/cart',
29+
method: 'POST',
30+
status: 201,
31+
durationMs: 5,
32+
side: 'client',
33+
cacheHit: false,
34+
faulted: false,
35+
at: 1,
36+
requestHeaders: { authorization: '[redacted]', 'content-type': 'application/json' },
37+
},
38+
],
39+
},
40+
]);
41+
await fixture.whenStable();
42+
fixture.componentInstance.openCall('c1');
43+
await fixture.whenStable();
44+
}
45+
46+
describe('NetworkInspector copy as fetch and cURL', () => {
47+
beforeAll(() => {
48+
Element.prototype.scrollIntoView ??= () => {};
49+
});
50+
51+
afterEach(() => {
52+
written.length = 0;
53+
fixture?.destroy();
54+
TestBed.resetTestingModule();
55+
});
56+
57+
it('copies the masked call as a curl command and as fetch', async () => {
58+
Object.defineProperty(navigator, 'clipboard', {
59+
configurable: true,
60+
value: { writeText: (text: string) => (written.push(text), Promise.resolve()) },
61+
});
62+
await openCall();
63+
const curl = button('Copy as cURL');
64+
expect(curl.getAttribute('aria-label')).toBe('Copy POST /api/cart as cURL');
65+
curl.click();
66+
await fixture.whenStable();
67+
expect(written[0]).toContain("-X 'POST'");
68+
expect(written[0]).toContain("-H 'authorization: [redacted]'");
69+
expect(status()).toBe('Copied as cURL. Fill in the masked values first.');
70+
71+
button('Copy as fetch').click();
72+
await fixture.whenStable();
73+
expect(written[1]).toContain('await fetch(');
74+
expect(written[1]).toContain('"method": "POST"');
75+
});
76+
77+
it('says when the clipboard is not available', async () => {
78+
Object.defineProperty(navigator, 'clipboard', {
79+
configurable: true,
80+
value: { writeText: () => Promise.reject(new Error('denied')) },
81+
});
82+
await openCall();
83+
button('Copy as fetch').click();
84+
await fixture.whenStable();
85+
await new Promise((resolve) => setTimeout(resolve));
86+
await fixture.whenStable();
87+
expect(status()).toBe('The clipboard is not available here.');
88+
});
89+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
3+
import { NetworkInspector } from '../pages/network-inspector';
4+
5+
let fixture: ComponentFixture<NetworkInspector>;
6+
7+
const call = (id: string, origin?: 'httpResource' | 'resource' | 'defer') => ({
8+
id,
9+
url: `/api/${id}`,
10+
method: 'GET',
11+
status: 200,
12+
durationMs: 5,
13+
side: 'client' as const,
14+
cacheHit: false,
15+
faulted: false,
16+
at: 1,
17+
...(origin ? { origin } : {}),
18+
});
19+
20+
describe('NetworkInspector call origin', () => {
21+
beforeAll(() => {
22+
Element.prototype.scrollIntoView ??= () => {};
23+
});
24+
25+
afterEach(() => {
26+
fixture?.destroy();
27+
TestBed.resetTestingModule();
28+
});
29+
30+
it('tags calls with the API that likely started them', async () => {
31+
fixture = TestBed.createComponent(NetworkInspector);
32+
document.body.append(fixture.nativeElement);
33+
fixture.componentInstance.pages.set([
34+
{
35+
pageId: 'p1',
36+
url: '/',
37+
title: 'Home',
38+
hydration: null,
39+
reportedAt: 1,
40+
calls: [call('a', 'httpResource'), call('b', 'resource'), call('c', 'defer'), call('d')],
41+
},
42+
]);
43+
await fixture.whenStable();
44+
const host = fixture.nativeElement as HTMLElement;
45+
const tags = [...host.querySelectorAll('app-call-origin .tag')];
46+
expect(tags.map((t) => t.textContent!.trim()).sort()).toEqual([
47+
'likely @defer',
48+
'likely httpResource()',
49+
'likely resource()',
50+
]);
51+
expect(tags[0].getAttribute('title')).toContain('guessed from the call stack');
52+
});
53+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
3+
import { NetworkInspector } from '../pages/network-inspector';
4+
import { harFileName } from '../pages/network/har-export';
5+
6+
let fixture: ComponentFixture<NetworkInspector>;
7+
8+
const call = (overrides: Record<string, unknown>) => ({
9+
id: 'c',
10+
url: '/api/products',
11+
method: 'GET',
12+
status: 200,
13+
durationMs: 10,
14+
side: 'client' as const,
15+
cacheHit: false,
16+
faulted: false,
17+
at: 1000,
18+
pageUrl: '/shop',
19+
...overrides,
20+
});
21+
22+
const host = () => fixture.nativeElement as HTMLElement;
23+
const exportButton = () =>
24+
[...host().querySelectorAll<HTMLButtonElement>('app-har-export button')].find(
25+
(b) => b.textContent!.trim() === 'Export HAR',
26+
)!;
27+
28+
describe('harFileName', () => {
29+
it('names the file after the page path', () => {
30+
expect(harFileName('/shop/7?tab=reviews')).toBe('pangular-shop-7.har');
31+
expect(harFileName('/')).toBe('pangular-home.har');
32+
expect(harFileName(undefined)).toBe('pangular-home.har');
33+
});
34+
});
35+
36+
describe('NetworkInspector HAR export', () => {
37+
beforeAll(() => {
38+
Element.prototype.scrollIntoView ??= () => {};
39+
});
40+
41+
afterEach(() => {
42+
vi.restoreAllMocks();
43+
fixture?.destroy();
44+
TestBed.resetTestingModule();
45+
});
46+
47+
it('is off until the timeline has a call', async () => {
48+
fixture = TestBed.createComponent(NetworkInspector);
49+
document.body.append(fixture.nativeElement);
50+
await fixture.whenStable();
51+
expect(exportButton().disabled).toBe(true);
52+
});
53+
54+
it('saves the timeline and the SSR request as a HAR file', async () => {
55+
fixture = TestBed.createComponent(NetworkInspector);
56+
document.body.append(fixture.nativeElement);
57+
const inspector = fixture.componentInstance;
58+
inspector.requests.set([
59+
{
60+
id: 'r1',
61+
method: 'GET',
62+
url: '/shop',
63+
status: 200,
64+
at: 500,
65+
durationMs: 80,
66+
renderMs: 60,
67+
bytes: 100,
68+
renderMode: 'server',
69+
fetches: 1,
70+
fetchMs: 5,
71+
headers: { 'server-timing': 'render;dur=60' },
72+
},
73+
]);
74+
inspector.serverCalls.set([call({ id: 's1', side: 'server', requestId: 'r1', at: 600 })]);
75+
inspector.pages.set([
76+
{
77+
pageId: 'p1',
78+
url: '/shop',
79+
title: 'Shop',
80+
hydration: null,
81+
ssrRequestId: 'r1',
82+
reportedAt: 1,
83+
calls: [call({ id: 'c1', requestHeaders: { authorization: '[redacted]' } })],
84+
},
85+
]);
86+
await fixture.whenStable();
87+
88+
let blob: Blob | undefined;
89+
const create = vi.fn((value: Blob) => {
90+
blob = value;
91+
return 'blob:har';
92+
});
93+
Object.assign(URL, { createObjectURL: create, revokeObjectURL: vi.fn() });
94+
let downloaded = '';
95+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
96+
this: HTMLAnchorElement,
97+
) {
98+
downloaded = this.download;
99+
});
100+
101+
exportButton().click();
102+
await fixture.whenStable();
103+
104+
expect(downloaded).toBe('pangular-shop.har');
105+
const har = JSON.parse(await blob!.text());
106+
expect(har.log.entries.map((e: { _side: string }) => e._side)).toEqual([
107+
'document',
108+
'server',
109+
'client',
110+
]);
111+
expect(har.log.entries[2].request.headers).toEqual([
112+
{ name: 'authorization', value: '[redacted]' },
113+
]);
114+
expect(host().querySelector('.toolbar .message')!.textContent!.trim()).toBe(
115+
'Saved 3 requests as pangular-shop.har.',
116+
);
117+
});
118+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
2+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
3+
import { NetworkInspector } from '../pages/network-inspector';
4+
5+
let fixture: ComponentFixture<NetworkInspector>;
6+
7+
const call = (id: string, at: number, overrides: Record<string, unknown> = {}) => ({
8+
id,
9+
url: `/api/${id}`,
10+
method: 'GET',
11+
status: 200,
12+
durationMs: 100,
13+
side: 'client' as const,
14+
cacheHit: false,
15+
faulted: false,
16+
at,
17+
pageUrl: '/shop',
18+
...overrides,
19+
});
20+
21+
const host = () => fixture.nativeElement as HTMLElement;
22+
const lint = () => host().querySelector('app-http-lint')!;
23+
24+
async function mount(
25+
calls: ReturnType<typeof call>[],
26+
serverCalls: ReturnType<typeof call>[] = [],
27+
) {
28+
fixture = TestBed.createComponent(NetworkInspector);
29+
document.body.append(fixture.nativeElement);
30+
const inspector = fixture.componentInstance;
31+
inspector.serverCalls.set(serverCalls);
32+
inspector.pages.set([
33+
{
34+
pageId: 'p1',
35+
url: '/shop',
36+
title: 'Shop',
37+
hydration: null,
38+
ssrRequestId: 'r1',
39+
reportedAt: 1,
40+
calls,
41+
},
42+
]);
43+
await fixture.whenStable();
44+
return inspector;
45+
}
46+
47+
describe('NetworkInspector HTTP lint', () => {
48+
beforeAll(() => {
49+
Element.prototype.scrollIntoView ??= () => {};
50+
});
51+
52+
afterEach(() => {
53+
fixture?.destroy();
54+
TestBed.resetTestingModule();
55+
});
56+
57+
it('has its own section with an h2', async () => {
58+
await mount([]);
59+
const heading = host().querySelector('#lint-heading')!;
60+
expect(heading.tagName).toBe('H2');
61+
expect(heading.textContent!.trim()).toBe('HTTP lint');
62+
expect(lint().querySelector('.none')!.textContent!.trim()).toBe(
63+
'No client calls to check yet.',
64+
);
65+
});
66+
67+
it('says when nothing was found', async () => {
68+
await mount([call('a', 0)]);
69+
expect(lint().querySelector('.none.ok')!.textContent!.trim()).toBe(
70+
'No duplicate requests, waterfalls or repeated server calls.',
71+
);
72+
});
73+
74+
it('lists duplicates, waterfalls and repeated server calls, and opens the first call', async () => {
75+
const inspector = await mount(
76+
[
77+
call('a', 0, { url: '/api/user' }),
78+
call('b', 300, { url: '/api/user' }),
79+
call('c', 5000),
80+
call('d', 5110),
81+
call('e', 5220),
82+
call('f', 9000, { url: '/api/p' }),
83+
],
84+
[call('s1', 0, { side: 'server', url: 'http://api/api/p', requestId: 'r1' })],
85+
);
86+
const items = [...lint().querySelectorAll('.findings li')];
87+
expect(items.map((li) => li.querySelector('.kind')!.textContent!.trim())).toEqual([
88+
'Duplicate',
89+
'Waterfall',
90+
'Repeated server call',
91+
]);
92+
expect(items[0].querySelector('.message')!.textContent).toContain(
93+
'GET /api/user ran 2 times within 300 ms.',
94+
);
95+
const show = items[1].querySelector('button')!;
96+
expect(show.getAttribute('aria-label')).toContain('Show the first call of: 3 calls ran');
97+
show.click();
98+
await fixture.whenStable();
99+
expect(inspector.selectedCall()?.id).toBe('c');
100+
});
101+
});

0 commit comments

Comments
 (0)