Skip to content
Merged
187 changes: 187 additions & 0 deletions app/src/__tests__/cd-diagnosis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { TestBed, type ComponentFixture } from '@angular/core/testing';
import type { CdDiagnosis as Diagnosis } from '@pangular-inspector/devtools/config';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CdDiagnosis } from '../pages/cd-diagnosis';
import { CdRecording, type CdPage } from '../pages/cd-recording';

const diagnosis: Diagnosis = {
triggers: true,
waste: true,
major: 22,
causes: { signal: 4, event: 2, zone: 1 },
components: [
{ name: 'Banner', refreshed: 20, unchanged: 19, strategy: 'Eager' },
{ name: 'Cart', refreshed: 5, unchanged: 0, strategy: 'OnPush' },
],
inputs: [{ name: 'Row', input: 'items', parent: 'List', inPlace: 3, sameContents: 1 }],
zone: [{ name: 'Clock', ticks: 1, tasks: ['setTimeout'] }],
};

function page(extra: Partial<CdPage> = {}): CdPage {
return {
pageId: 'p1',
supported: true,
recording: false,
startedAt: 1000,
dropped: 0,
cycles: [
{
id: 1,
at: 1001,
ms: 1,
passes: 1,
checks: 2,
components: [{ name: 'Cart', checks: 1, ms: 0.5 }],
trigger: 'Cart (add)',
cause: {
kind: 'event',
event: 'click',
target: 'button',
component: 'Cart',
listener: 'add',
},
refreshed: 2,
unchanged: 1,
},
],
components: [{ name: 'Cart', checks: 2, ms: 1, maxMs: 1, cycles: 1 }],
hosts: {},
diagnosis,
...extra,
};
}

const text = (el: Element | null) => el?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
/** A table row's cells, joined with spaces. */
const cells = (row: Element | null) =>
[...(row?.querySelectorAll('td') ?? [])].map((cell) => text(cell)).join(' ');

describe('CdDiagnosis', () => {
let fixture: ComponentFixture<CdDiagnosis>;
const host = () => fixture.nativeElement as HTMLElement;
const section = (heading: string) =>
[...host().querySelectorAll('h4')].find((h) => text(h) === heading)?.nextElementSibling ?? null;

beforeEach(() => {
fixture = TestBed.createComponent(CdDiagnosis);
});

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

async function show(value: CdPage | null) {
fixture.componentRef.setInput('page', value);
await fixture.whenStable();
}

it('shows nothing for a recording without a diagnosis', async () => {
await show(page({ diagnosis: undefined }));
expect(host().querySelector('section')).toBeNull();
});

it('lists causes in a fixed order, zone.js dependents, wasted checks, inputs and hints', async () => {
await show(page());
const heading = host().querySelector('section')!.getAttribute('aria-labelledby')!;
expect(text(host().querySelector(`#${heading}`))).toBe('Why it ran and what it wasted');
const causes = [...section('What started the cycles')!.querySelectorAll('tbody tr')].map(cells);
expect(causes).toEqual(['Event 2', 'Signal write 4', 'zone.js only 1']);
expect(cells(section('Depends on zone.js')!.querySelector('tbody tr'))).toBe(
'Clock 1 setTimeout',
);
const wasted = [...section('Wasted checks')!.querySelectorAll('tbody tr')].map(cells);
expect(wasted).toEqual(['Banner 20 19 (95%) Eager']);
expect(cells(section('Inputs that break OnPush')!.querySelector('tbody tr'))).toBe(
'Row.items List 3 1',
);
const hints = [...section('Hints')!.querySelectorAll('li')].map(text);
expect(hints[0]).toContain('Banner was refreshed 20 times and changed something in 1');
expect(hints[0]).toContain('default from Angular 22');
expect(
hints.some((h) => h.includes('Input items of Row set by List changed in place 3 times')),
).toBe(true);
expect(
hints.some((h) => h.startsWith('Clock changed in 1 tick that only zone.js started')),
).toBe(true);
});

it('says why causes or wasted checks are missing', async () => {
await show(
page({ diagnosis: { ...diagnosis, triggers: false, waste: false, zone: undefined } }),
);
expect(text(section('What started the cycles'))).toContain('needs Angular 18 or later');
expect(text(section('Wasted checks'))).toContain("can't watch DOM updates");
expect(section('Depends on zone.js')).toBeNull();
});

it('says when zone.js ticks changed nothing', async () => {
await show(page({ diagnosis: { ...diagnosis, zone: undefined } }));
expect(text(section('Depends on zone.js'))).toBe(
'1 tick came from zone.js alone, and no component changed in them.',
);
});
});

describe('CdDiagnosis and the zoneJs flag', () => {
afterEach(() => TestBed.resetTestingModule());

async function zoneSection(zoneJs: boolean) {
const fixture = TestBed.createComponent(CdDiagnosis);
fixture.componentRef.setInput(
'page',
page({ diagnosis: { ...diagnosis, causes: { signal: 4 }, zone: [], zoneJs } }),
);
await fixture.whenStable();
const h4 = [...(fixture.nativeElement as HTMLElement).querySelectorAll('h4')].find(
(h) => h.textContent?.trim() === 'Depends on zone.js',
);
return h4?.parentElement ?? null;
}

it('shows the zone.js block on a zone.js page even when zone.js started no tick', async () => {
expect((await zoneSection(true))?.textContent).toContain(
'No component changed in a tick only zone.js started.',
);
});

it('hides it on a zoneless page', async () => {
expect(await zoneSection(false)).toBeNull();
});
});

describe('CdDiagnosis on a zoneless page', () => {
afterEach(() => TestBed.resetTestingModule());

it('hides the zone.js block when no tick came from zone.js', async () => {
const fixture = TestBed.createComponent(CdDiagnosis);
fixture.componentRef.setInput(
'page',
page({ diagnosis: { ...diagnosis, causes: { signal: 4, event: 2 }, zone: [] } }),
);
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const headings = [...host.querySelectorAll('h4')].map((h) => text(h));
expect(headings).toContain('What started the cycles');
expect(headings).not.toContain('Depends on zone.js');
expect(host.textContent).not.toContain('zone.js started');
fixture.destroy();
});
});

describe('CdRecording latest cycles', () => {
it('names what started a cycle and how many checks changed nothing', async () => {
const fixture = TestBed.createComponent(CdRecording);
fixture.componentRef.setInput('page', page());
await fixture.whenStable();
const row = (fixture.nativeElement as HTMLElement).querySelector('.cycles li');
expect(text(row)).toContain('from click on button in Cart (add)');
expect(text(row)).toContain('1 changed nothing');
expect(text(row)).not.toContain('after Cart (add)');
expect(
(fixture.nativeElement as HTMLElement).querySelector('app-cd-diagnosis section'),
).not.toBeNull();
fixture.destroy();
TestBed.resetTestingModule();
});
});
123 changes: 123 additions & 0 deletions app/src/__tests__/component-tree-leaks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ComponentTree } from '../pages/component-tree';
import type { TreeNode } from '../pages/component-tree-state';
import { LeaksSection } from '../pages/leaks-section';
import { keepBelowSticky, scrollParent, stickyClearance } from '../ui/sticky-clearance';

const roots: TreeNode[] = [
{
id: 'c1',
name: 'App',
tag: 'app-root',
children: [
{
id: 'c2',
name: 'TripList',
tag: 'app-trip-list',
children: [{ id: 'c3', name: 'TripCard', tag: 'app-trip-card', children: [] }],
},
],
},
];

function client() {
const rpc = {
call: () => Promise.resolve([]),
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve({
value: () =>
name === 'component-tree'
? { pages: { p1: { pageId: 'p1', roots, count: 3, detail: null, reportedAt: 1000 } } }
: null,
on: () => () => {},
}),
};
return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient;
}

describe('component tree and the Leaks section', () => {
beforeEach(() => {
vi.stubGlobal('CSS', { escape: (value: string) => value });
Element.prototype.scrollIntoView ??= () => {};
});
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
TestBed.resetTestingModule();
});

it('moves focus to the tree row of a Leaks row and keeps it clear of the toolbar', async () => {
const fixture = TestBed.createComponent(ComponentTree);
fixture.componentRef.setInput('rpc', client());
// The panel's scroller: the toolbar sticks inside its padding.
const main = document.createElement('main');
main.style.overflowY = 'auto';
main.style.paddingTop = '20px';
main.append(fixture.nativeElement);
document.body.append(main);
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setTimeout(resolve));
await fixture.whenStable();
}
const el = fixture.nativeElement as HTMLElement;
Object.defineProperty(el.querySelector('.toolbar')!, 'offsetHeight', { value: 64 });
const scrolled = vi.spyOn(Element.prototype, 'scrollIntoView');
const leaks = fixture.debugElement.query(By.directive(LeaksSection));
expect(leaks).not.toBeNull();
(leaks.componentInstance as LeaksSection).showComponent.emit('c3');
fixture.detectChanges();
await fixture.whenStable();
const row = el.querySelector<HTMLElement>('.row[data-id="c3"]')!;
expect(row.getAttribute('aria-selected')).toBe('true');
expect(document.activeElement).toBe(row);
expect(scrolled).toHaveBeenCalled();
// The scroller's padding, the toolbar's height and an 8px gap.
expect(el.style.getPropertyValue('--ct-toolbar-clearance')).toBe('92px');
});

it('measures the clearance a sticky bar needs, with the scroller padding and scroll padding', () => {
const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
scroller.style.paddingTop = '16px';
const wrap = document.createElement('section');
const bar = document.createElement('div');
bar.style.top = '4px';
Object.defineProperty(bar, 'offsetHeight', { value: 100 });
wrap.append(bar);
scroller.append(wrap);
document.body.append(scroller);
expect(scrollParent(bar)).toBe(scroller);
expect(stickyClearance(bar)).toBe(16 + 4 + 100 + 8);
scroller.style.scrollPaddingTop = '10px';
expect(stickyClearance(bar, 0)).toBe(16 + 4 + 100 - 10);
// Without a scrolling ancestor the page scrolls, with no padding to add.
const loose = document.createElement('div');
Object.defineProperty(loose, 'offsetHeight', { value: 50 });
document.body.append(loose);
expect(scrollParent(loose)).toBeNull();
expect(stickyClearance(loose)).toBe(58);
});

it('scrolls a row the sticky bar covers back below it, and leaves a clear row alone', () => {
const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
const bar = document.createElement('div');
const row = document.createElement('div');
scroller.append(bar, row);
document.body.append(scroller);
scroller.scrollTop = 400;
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
bar.getBoundingClientRect = () => rect(65, 117);
row.getBoundingClientRect = () => rect(92, 124);
keepBelowSticky(row, bar);
// 117 + 8 - 92: the row moves 33px down.
expect(scroller.scrollTop).toBe(367);
row.getBoundingClientRect = () => rect(130, 162);
keepBelowSticky(row, bar);
expect(scroller.scrollTop).toBe(367);
});
});
Loading
Loading