diff --git a/app/src/__tests__/component-tree-defer.test.ts b/app/src/__tests__/component-tree-defer.test.ts new file mode 100644 index 00000000..a07a00c4 --- /dev/null +++ b/app/src/__tests__/component-tree-defer.test.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; + +function client(pages: Record): DevframeRpcClient { + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ + value: () => (name === 'component-tree' ? { pages } : null), + on: () => () => {}, + }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +describe('defer blocks', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('style the state badge apart from the empty-state box', 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', + owner: { id: 'c1', name: 'App', tag: 'app-root' }, + state: 'complete', + hydration: 'not-configured', + triggers: ['viewport'], + 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')!; + const badge = Array.from(row.querySelectorAll('span')).find( + (el) => el.textContent?.trim() === 'complete', + )!; + expect(badge.classList.contains('state')).toBe(false); + expect(badge.classList.contains('defer-state')).toBe(true); + expect(badge.classList.contains('defer-state-complete')).toBe(true); + }); +}); diff --git a/app/src/__tests__/di-inspector-pages.test.ts b/app/src/__tests__/di-inspector-pages.test.ts new file mode 100644 index 00000000..d951f37d --- /dev/null +++ b/app/src/__tests__/di-inspector-pages.test.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { scopeToPage } from '../page-id'; +import { DiInspector } from '../pages/di-inspector'; + +const node = (name: string) => ({ + injector: { name, kind: 'element' }, + providers: [], + children: [], +}); + +function client(shared: unknown, calls: string[] = []): DevframeRpcClient { + const rpc = { + call: (name: string) => { + calls.push(name); + return Promise.resolve([]); + }, + callEvent: () => Promise.resolve(), + sharedState: () => + Promise.resolve({ + value: () => shared, + on: () => () => {}, + }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +async function render(shared: unknown) { + const fixture = TestBed.createComponent(DiInspector); + fixture.componentRef.setInput('rpc', client(shared)); + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + return fixture; +} + +describe('injector tree page selection', () => { + afterEach(() => { + scopeToPage(null); + document.body.innerHTML = ''; + }); + + it('never shows another tab when its own page has not reported', async () => { + scopeToPage('A'); + const fixture = await render({ + roots: [node('from-B')], + environment: [node('env-B')], + pages: { B: { roots: [node('from-B')], environment: [node('env-B')] } }, + }); + expect(fixture.componentInstance.roots()).toEqual([]); + expect(fixture.componentInstance.environment()).toEqual([]); + }); + + it('shows its own page when it has reported', async () => { + scopeToPage('A'); + const fixture = await render({ + roots: [node('from-B')], + pages: { A: { roots: [node('from-A')], environment: [] }, B: { roots: [node('from-B')] } }, + }); + expect(fixture.componentInstance.roots().map((n) => n.injector.name)).toEqual(['from-A']); + }); + + it('follows the shared top level when the panel has no page id', async () => { + const fixture = await render({ roots: [node('shared')], environment: [] }); + expect(fixture.componentInstance.roots().map((n) => n.injector.name)).toEqual(['shared']); + }); +}); diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts index 5d2115cf..1159a381 100644 --- a/app/src/__tests__/router-panels.test.ts +++ b/app/src/__tests__/router-panels.test.ts @@ -76,6 +76,33 @@ describe('RouteTree row actions', () => { ], }); + it('shows the param it will navigate with after the row is hidden and shown again', async () => { + const calls: Record[] = []; + const fixture = mount(RouteTree, config, (_, arg) => { + calls.push(arg); + return Promise.resolve({ id: 4, outcome: 'succeeded', finalUrl: '/users/42' }); + }); + await settle(fixture); + const first = el(fixture).querySelector('input.param')!; + first.value = '42'; + first.dispatchEvent(new Event('input')); + + const filter = el(fixture).querySelector('input.filter')!; + filter.value = 'lazy'; + filter.dispatchEvent(new Event('input')); + await settle(fixture); + expect(el(fixture).querySelector('input.param')).toBeNull(); + filter.value = ''; + filter.dispatchEvent(new Event('input')); + await settle(fixture); + + const again = el(fixture).querySelector('input.param')!; + expect(again.value).toBe('42'); + button(fixture, 'Navigate to users/:id').click(); + await settle(fixture); + expect(calls[0]).toMatchObject({ request: { params: { id: '42' } } }); + }); + it('asks for empty params next to the row instead of calling the page', async () => { const calls: Record[] = []; const fixture = mount(RouteTree, config, (_, arg) => { diff --git a/app/src/__tests__/signal-inspector.test.ts b/app/src/__tests__/signal-inspector.test.ts index 611e1302..4e74577c 100644 --- a/app/src/__tests__/signal-inspector.test.ts +++ b/app/src/__tests__/signal-inspector.test.ts @@ -51,4 +51,45 @@ describe('SignalInspector', () => { const fixture = await render(graph); expect(notices(fixture).some((text) => text.includes('write hook'))).toBe(false); }); + + it('stays on the first page it follows when a second tab reports later', async () => { + const listeners = new Set<(value: unknown) => void>(); + const owned = (pageId: string, id: string) => ({ + ...graph, + pageId, + component: { ...graph.component, id }, + }); + const a = owned('A', 'cA'); + const b = owned('B', 'cB'); + const state = (value: unknown) => + Promise.resolve({ + value: () => value, + on: (_event: string, listener: (value: unknown) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }); + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + state(name === 'signal-graph' ? { graph: a, pages: { A: a } } : { pages: {} }), + }; + const fixture = TestBed.createComponent(SignalInspector); + fixture.componentRef.setInput('rpc', { + connectionMeta: {}, + scope: () => ({ rpc }), + } as unknown as DevframeRpcClient); + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + expect(fixture.componentInstance.graph()?.pageId).toBe('A'); + + for (const listener of listeners) listener({ graph: b, pages: { A: a, B: b } }); + expect(fixture.componentInstance.graph()?.pageId).toBe('A'); + + for (const listener of listeners) listener({ graph: b, pages: { B: b } }); + expect(fixture.componentInstance.graph()?.pageId).toBe('B'); + }); }); diff --git a/app/src/__tests__/store-inspector.test.ts b/app/src/__tests__/store-inspector.test.ts index 5dc6cda1..045cb18f 100644 --- a/app/src/__tests__/store-inspector.test.ts +++ b/app/src/__tests__/store-inspector.test.ts @@ -280,3 +280,19 @@ describe('StoreInspector signal store', () => { expect(resetChip?.textContent).not.toContain('ms'); }); }); + +describe('StoreInspector source declarations', () => { + it('keeps only list items inside the declaration list when a filter matches nothing', async () => { + const { fixture } = await mount([entry(1)]); + const list = () => root(fixture).querySelector('#ngrx-source-heading')!.closest('section')!; + type(root(fixture).querySelector('input[type="search"], input')!, 'zzz'); + await settle(fixture); + const nodes = list().querySelector('ul.nodes')!; + expect(nodes.textContent).toContain('No declarations match.'); + for (const child of Array.from(nodes.children)) { + expect(child.tagName).toBe('LI'); + expect(child.getAttribute('role')).toBeNull(); + } + expect(nodes.querySelector('[role="status"]')).not.toBeNull(); + }); +}); diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 9bce8739..07123c41 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -502,7 +502,9 @@ function bare(name: string): string { (mouseleave)="highlight(null)" > {{ block.owner?.name ?? 'Unknown component' }} - {{ block.state }} + {{ + block.state + }} @if (block.hydrateNever) { hydrate never } @else if (block.hydration !== 'not-configured') { @@ -831,7 +833,7 @@ function bare(name: string): string { .defer-row:disabled { cursor: default; } - .state { + .defer-state { padding: 0 8px; border-radius: 99px; background: var(--surface-3); @@ -839,14 +841,14 @@ function bare(name: string): string { font-size: 11px; line-height: 18px; } - .state-complete { + .defer-state-complete { @include m.soft(var(--ok)); } - .state-error { + .defer-state-error { @include m.soft(var(--danger)); } - .state-loading, - .state-placeholder { + .defer-state-loading, + .defer-state-placeholder { @include m.soft(var(--warn)); } .triggers { diff --git a/app/src/pages/di-inspector.ts b/app/src/pages/di-inspector.ts index fbe1b414..109adb7e 100644 --- a/app/src/pages/di-inspector.ts +++ b/app/src/pages/di-inspector.ts @@ -1554,9 +1554,9 @@ export class DiInspector { truncated?: unknown; pages?: Record; } | null; - const next = (pageId ? shared?.pages?.[pageId] : undefined) ?? shared; - if (isTree(next?.roots)) this.roots.set(next.roots); - if (isTree(next?.environment)) this.environment.set(next.environment); + const next = pageId ? shared?.pages?.[pageId] : shared; + this.roots.set(isTree(next?.roots) ? next.roots : []); + this.environment.set(isTree(next?.environment) ? next.environment : []); this.truncated.set(next?.truncated === true); }; apply(state.value()); diff --git a/app/src/pages/route-tree.ts b/app/src/pages/route-tree.ts index 0872d9ce..8176b4de 100644 --- a/app/src/pages/route-tree.ts +++ b/app/src/pages/route-tree.ts @@ -203,6 +203,7 @@ interface MatchResult { isInvalid(row.node, param) ? 'route-tree-row-result' : null " [placeholder]="param" + [value]="paramValues()[row.node.id]?.[param] ?? ''" (input)="setParam(row.node.id, param, $any($event.target).value)" /> } @@ -406,7 +407,7 @@ export class RouteTree { readonly match = signal(null); readonly message = signal(''); readonly rowResult = signal<{ id: string; text: string } | null>(null); - private readonly paramValues = signal>>({}); + protected readonly paramValues = signal>>({}); private readonly checkedRow = signal(null); readonly active = computed(() => new Set(this.page().activeIds ?? [])); diff --git a/app/src/pages/signal-inspector.ts b/app/src/pages/signal-inspector.ts index c91149b7..97f5b460 100644 --- a/app/src/pages/signal-inspector.ts +++ b/app/src/pages/signal-inspector.ts @@ -1362,7 +1362,7 @@ export class SignalInspector { if (this.destroyRef.destroyed) return; const apply = (value: unknown) => { const next = value as { graph?: SignalGraph | null; pages?: Record }; - const reported = (this.pageId ? next?.pages?.[this.pageId] : next?.graph) ?? null; + const reported = this.reportedGraph(next); this.unsupported.set(!!reported?.unsupported); const graph = reported?.unsupported ? null : reported; const owner = (g: SignalGraph | null) => g?.component?.id ?? g?.injector?.id; @@ -1388,6 +1388,20 @@ export class SignalInspector { } } + private lockedPage: string | null = null; + + private reportedGraph(next: { + graph?: SignalGraph | null; + pages?: Record; + }): SignalGraph | null { + if (this.pageId) return next?.pages?.[this.pageId] ?? null; + const locked = this.lockedPage ? next?.pages?.[this.lockedPage] : undefined; + if (locked) return locked; + const latest = next?.graph ?? null; + this.lockedPage = latest?.pageId ?? null; + return latest; + } + pickComponent(value: string | null) { const picked = value && value !== FOLLOW ? value : null; this.picked.set(picked); diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index 06856d07..9d6a4476 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -775,9 +775,11 @@ const CLASSIC_KINDS = new Set([ } } @empty { -
  • -

    No declarations match.

    - +
  • +
    +

    No declarations match.

    + +
  • } diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 8bdd9e88..23e4572a 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -288,6 +288,8 @@ When a write goes through a native `