diff --git a/app/src/__tests__/component-tree-defer.test.ts b/app/src/__tests__/component-tree-defer.test.ts index a07a00c4..0d22e685 100644 --- a/app/src/__tests__/component-tree-defer.test.ts +++ b/app/src/__tests__/component-tree-defer.test.ts @@ -53,4 +53,37 @@ describe('defer blocks', () => { expect(badge.classList.contains('defer-state')).toBe(true); expect(badge.classList.contains('defer-state-complete')).toBe(true); }); + + it('keeps a row without an owner focusable, marked aria-disabled', async () => { + const fixture = TestBed.createComponent(ComponentTree); + const page = { + pageId: 'p1', + roots: [{ id: 'c1', name: 'App', tag: 'app-root', children: [] }], + count: 1, + detail: null, + reportedAt: 1000, + deferBlocks: [ + { + id: 'd1', + state: 'placeholder', + hydration: 'not-configured', + triggers: [], + rootIds: [], + }, + ], + }; + fixture.componentRef.setInput('rpc', client({ p1: page })); + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + const row = (fixture.nativeElement as HTMLElement).querySelector( + '.defer-row', + )!; + expect(row.disabled).toBe(false); + expect(row.getAttribute('aria-disabled')).toBe('true'); + row.click(); + await fixture.whenStable(); + expect(row.textContent).toContain('Unknown component'); + }); }); diff --git a/app/src/__tests__/di-inspector-pages.test.ts b/app/src/__tests__/di-inspector-pages.test.ts index 638ae5ed..46b2f068 100644 --- a/app/src/__tests__/di-inspector-pages.test.ts +++ b/app/src/__tests__/di-inspector-pages.test.ts @@ -503,3 +503,66 @@ describe('injector tree page picker', () => { expect(el.textContent).not.toContain('This injector is no longer on the page'); }); }); + +describe('injector tree search and zone', () => { + afterEach(() => { + scopeToPage(null); + document.body.innerHTML = ''; + }); + + const providing = (count: number) => + Array.from({ length: count }, (_, i) => ({ + injector: { + id: `e${i}`, + type: 'element', + name: `app-${i}`, + providerCount: 1, + component: 'C', + }, + providers: [{ token: 'Store', type: 'class', isViewProvider: false }], + children: [], + })); + + it('shows the change detection mode the page reported', async () => { + const fixture = await render({ roots: providing(1), environment: [], zone: 'zone-unused' }); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Change detection: Zoneless, zone.js loaded', + ); + }); + + it('says how many more injectors provide the token past the shown chips', async () => { + const fixture = await render({ roots: providing(15), environment: [] }); + fixture.componentInstance.query.set('store'); + await fixture.whenStable(); + const where = (fixture.nativeElement as HTMLElement).querySelector('.where')!; + expect(where.querySelectorAll('.chip')).toHaveLength(12); + expect(where.textContent).toContain('+3 more'); + expect(where.getAttribute('role')).toBeNull(); + }); + + it('announces one search summary once typing pauses', async () => { + const fixture = await render({ roots: providing(2), environment: [] }); + vi.useFakeTimers(); + try { + const status = () => + (fixture.nativeElement as HTMLElement).querySelector('[role="status"].sr-only')! + .textContent; + for (const query of ['s', 'st', 'sto', 'store']) { + fixture.componentInstance.query.set(query); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(100); + } + expect(status()).toBe(''); + await vi.advanceTimersByTimeAsync(600); + fixture.detectChanges(); + expect(status()).toBe('2 injectors provide “store”.'); + fixture.componentInstance.query.set('zzz'); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(600); + fixture.detectChanges(); + expect(status()).toBe('Nothing here matches “zzz”.'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/src/__tests__/pipes-record-page.test.ts b/app/src/__tests__/pipes-record-page.test.ts index ecc16be2..ad69a988 100644 --- a/app/src/__tests__/pipes-record-page.test.ts +++ b/app/src/__tests__/pipes-record-page.test.ts @@ -105,3 +105,109 @@ describe('pipes filter box', () => { expect(document.activeElement).toBe(host.querySelector('input[type="search"]')); }); }); + +describe('pipes live data per page', () => { + afterEach(() => { + scopeToPage(null); + document.body.innerHTML = ''; + }); + + const live = (count: number) => ({ + name: 'price', + className: 'PricePipe', + isPure: true, + instanceCount: count, + components: [{ name: 'Cart', count }], + }); + + function withPages(snapshot: unknown) { + const rpc = { + call: (name: string) => + Promise.resolve( + name === 'get-pipes' + ? [ + { + name: 'price', + className: 'PricePipe', + file: 'src/price.pipe.ts', + line: 4, + isStandalone: true, + isPure: true, + builtin: false, + usageCount: 2, + usages: [ + { file: 'src/cart.html', line: 3 }, + { file: 'src/total.html', line: 7 }, + ], + }, + ] + : [], + ), + callEvent: () => Promise.resolve(), + sharedState: () => Promise.resolve({ value: () => snapshot, on: () => () => {} }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; + } + + it('shows only the page the panel belongs to when several pages report', async () => { + scopeToPage('B'); + const fixture = await render( + withPages({ + pipes: [live(5)], + async: [], + reportedAt: 0, + instrumented: [], + pages: [ + { pageId: 'A', pipes: [live(3)], async: [] }, + { pageId: 'B', pipes: [live(2)], async: [] }, + ], + }), + ); + expect(fixture.componentInstance.liveFor('price')?.instanceCount).toBe(2); + }); + + it('lists the template usages of a custom pipe', async () => { + const fixture = await render( + withPages({ pipes: [], async: [], reportedAt: 0, instrumented: [] }), + ); + const component = fixture.componentInstance; + component.selected.set(component.pipes()[0]); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + expect(host.textContent).toContain('Used in templates'); + expect(host.textContent).toContain('src/total.html:7'); + expect(host.textContent).toContain('2 uses'); + }); + + it('names the component and caller of each called instance', async () => { + const fixture = await render( + withPages({ + pipes: [ + { + ...live(2), + call: { + callCount: 3, + totalMs: 1.5, + maxMs: 1, + lastMs: 0.5, + instances: [ + { component: 'Cart', callCount: 2, lastCaller: 'Cart.total (cart.ts:4)' }, + { component: 'Summary', callCount: 1, target: { pageId: 'A', id: 's1' } }, + ], + }, + }, + ], + async: [], + reportedAt: 0, + instrumented: [], + }), + ); + const component = fixture.componentInstance; + component.selected.set(component.pipes()[0]); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + expect(host.textContent).toContain('Cart.total (cart.ts:4)'); + expect(host.textContent).toContain('1.50 ms total'); + expect(host.querySelector('button[aria-label="Highlight Summary on the page"]')).not.toBeNull(); + }); +}); diff --git a/app/src/__tests__/signal-inspector.test.ts b/app/src/__tests__/signal-inspector.test.ts index cde4401b..44e3faf8 100644 --- a/app/src/__tests__/signal-inspector.test.ts +++ b/app/src/__tests__/signal-inspector.test.ts @@ -395,4 +395,54 @@ describe('SignalInspector', () => { expect(box.value).toBe(''); }); }); + + describe('relations, filters and write hints', () => { + const linked = { + ...graph, + nodes: [ + { id: '1', kind: 'signal', epoch: 2, value: 1 }, + { id: '2', kind: 'computed', label: 'double', epoch: 1, value: 2 }, + { id: '3', kind: 'signal', label: 'query', epoch: 3, value: 'x' }, + ], + edges: [{ consumer: 1, producer: 0 }], + history: { + '3': [ + { epoch: 1, value: 'a', at: 1, source: 'initial' }, + { epoch: 3, value: 'x', at: 2, source: 'sample', missed: 1 }, + ], + }, + resources: [{ id: 'resource:9', name: 'trips', named: true, epoch: 1, nodeIds: [] }], + }; + + it('lists producers and consumers from the edges', async () => { + const fixture = await render(linked); + const page = fixture.componentInstance; + const [unnamed, double] = linked.nodes as never[]; + expect(page.getDependencies(double).map((n: { id: string }) => n.id)).toEqual(['1']); + expect(page.getConsumers(unnamed).map((n: { id: string }) => n.id)).toEqual(['2']); + expect(page.getDependencies(unnamed)).toEqual([]); + }); + + it('matches resources by kind only on a prefix of three letters or more', async () => { + const fixture = await render(linked); + const page = fixture.componentInstance; + page.filter.set('e'); + expect(page.filteredResources()).toEqual([]); + page.filter.set('res'); + expect(page.filteredResources().map((r) => r.id)).toEqual(['resource:9']); + page.filter.set('trip'); + expect(page.filteredResources().map((r) => r.id)).toEqual(['resource:9']); + }); + + it('explains why an unnamed signal or a sampled-only history has no set entries', async () => { + const fixture = await render(linked); + const page = fixture.componentInstance; + page.selectedId.set('1'); + expect(page.writeHint()).toMatch(/has no name.*debugName/); + page.selectedId.set('3'); + expect(page.writeHint()).toMatch(/Every change here was sampled/); + page.selectedId.set('2'); + expect(page.writeHint()).toBe(''); + }); + }); }); diff --git a/app/src/pages/cd-recording.ts b/app/src/pages/cd-recording.ts index 82dbb2eb..287f12e1 100644 --- a/app/src/pages/cd-recording.ts +++ b/app/src/pages/cd-recording.ts @@ -1,41 +1,14 @@ import { Component, computed, input, signal } from '@angular/core'; +import type { CdReport } from '@pangular-inspector/devtools/config'; import type { DevframeRpcClient } from 'devframe/client'; import { panelConfig } from '../devtools-config'; import { time } from '../format'; import { rpcCall as call } from '../rpc'; import { LimitNote } from '../ui/limit-note'; -export interface CdCheck { - name: string; - checks: number; - ms: number; -} - -export interface CdCycle { - id: number; - at: number; - ms: number; - passes: number; - trigger?: string; - checks: number; - components: CdCheck[]; -} - -export interface CdComponentStat extends CdCheck { - maxMs: number; - cycles: number; -} - -export interface CdPage { - pageId: string; - supported: boolean; - recording: boolean; - startedAt: number | null; - dropped: number; - cycles: CdCycle[]; - components: CdComponentStat[]; - hosts: Record; -} +export type { CdCheck, CdComponentStat, CdCycle } from '@pangular-inspector/devtools/config'; +/** One page's recording, as the page reports it. */ +export type CdPage = CdReport; const SHOWN = 10; const SHOWN_CYCLES = 30; diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 6561644b..0c000e7b 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -521,7 +521,7 @@ function bare(name: string): string { } + @if (providedByMore(); as more) { + +{{ more }} more + } } @else if (query().trim() && !rows().length) { -
+
Nothing here matches “{{ query().trim() }}”.
@@ -794,6 +805,10 @@ function isTree(value: unknown): value is InjectorNode[] { color: var(--text-2); @include m.truncate; } + .more { + color: var(--text-2); + font-size: 12px; + } .link { padding: 0; border: 0; @@ -1383,6 +1398,10 @@ export class DiInspector { return isTree(environment) ? environment : []; }); readonly truncated = computed(() => this.shownPage()?.truncated === true); + readonly zone = computed(() => { + const zone = this.shownPage()?.zone; + return typeof zone === 'string' ? zoneLabel(zone) : null; + }); private readonly multiPage = computed( () => !this.hostPage && Object.keys(this.treePages()).length > 1, @@ -1516,7 +1535,8 @@ export class DiInspector { }); }); - readonly providedBy = computed(() => { + /** Every injector that provides a token matching the search. */ + private readonly providerHits = computed(() => { const q = this.normalizedQuery(); if (!q) return []; const hits: { id: string; label: string; kind: string; tokens: string }[] = []; @@ -1530,8 +1550,12 @@ export class DiInspector { tokens: tokens.map((t) => t.token).join(', '), }); } - return hits.slice(0, 12); + return hits; }); + readonly providedBy = computed(() => this.providerHits().slice(0, MAX_PROVIDED_BY)); + readonly providedByMore = computed(() => + Math.max(0, this.providerHits().length - MAX_PROVIDED_BY), + ); readonly groupedProviders = computed(() => { const q = this.normalizedQuery(); @@ -1554,13 +1578,40 @@ export class DiInspector { this.groupedProviders().reduce((sum, g) => sum + g.items.length, 0), ); + /** What a screen reader hears about the search results. */ + private readonly searchSummary = computed(() => { + const query = this.query().trim(); + if (!query) return ''; + const hits = this.providerHits().length; + if (hits === 1) return `1 injector provides “${query}”.`; + if (hits > 1) return `${hits} injectors provide “${query}”.`; + if (!this.rows().length) return `Nothing here matches “${query}”.`; + return `No injector provides “${query}”.`; + }); + private readonly normalizedQuery = computed(() => this.query().trim().toLowerCase()); private readonly destroyRef = inject(DestroyRef); private stopTree: (() => void) | null = null; private treeLoad = 0; + private lastSearchMessage = ''; + constructor() { + // One summary once typing pauses, instead of a live region that reads every keystroke. + effect((onCleanup) => { + const message = this.searchSummary(); + if (!message) { + this.lastSearchMessage = ''; + return; + } + if (message === this.lastSearchMessage) return; + const timer = setTimeout(() => { + this.lastSearchMessage = message; + this.announcement.set(message); + }, SEARCH_ANNOUNCE_MS); + onCleanup(() => clearTimeout(timer)); + }); effect(() => { const client = this.rpc(); if (!client) return; diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index ecd05422..1910eb1f 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -88,6 +88,7 @@ interface SsrRequest { headers: Record; navigations?: SsrNavigation[]; overrides?: { id: string; kind: SsrOverrideKind; applied: boolean; note?: string }[]; + compressedBeforeTrace?: boolean; aborted?: boolean; } @@ -417,6 +418,14 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
{{ requestPage()?.title || requestPage()?.url || 'not connected' }}
+ @if (compressedWith(req); as encoding) { +

+ The body was already compressed ({{ encoding }}) when + ssrMiddleware saw it, so the render mode can't be read and + TransferState edits can't apply. Mount ssrMiddleware after + compression(). +

+ } @if (req.overrides?.length) {

Overrides from the panel

@@ -745,7 +754,9 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ }