Skip to content

Commit cae29ab

Browse files
committed
feat(store): live NgRx Signal Store inspector
1 parent 6abd4b3 commit cae29ab

27 files changed

Lines changed: 2842 additions & 253 deletions

‎app/src/__tests__/store-inspector-focus.test.ts‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
22
import type { DevframeRpcClient } from 'devframe/client';
33
import { afterEach, describe, expect, it } from 'vitest';
44
import { StoreInspector } from '../pages/store-inspector';
5-
import type { NgrxPage, NgrxState } from '../pages/store-types';
5+
import type { NgrxLogEntry, NgrxPage, NgrxState } from '../pages/store-types';
66

77
afterEach(() => {
88
TestBed.resetTestingModule();
@@ -69,6 +69,106 @@ async function restoreNewest(result: { ok: boolean; paused?: boolean; message: s
6969
return fixture.nativeElement as HTMLElement;
7070
}
7171

72+
function eventPage(events: NgrxLogEntry[]): NgrxPage {
73+
return {
74+
pageId: 'p1',
75+
url: 'http://localhost/',
76+
title: 'Shop',
77+
stores: [],
78+
classic: null,
79+
log: events,
80+
reportedAt: 1,
81+
};
82+
}
83+
84+
function eventEntry(seq: number, type = 'bookingCancelled'): NgrxLogEntry {
85+
return {
86+
seq,
87+
source: 'event',
88+
storeId: 'event',
89+
type,
90+
eventType: type,
91+
payload: { id: seq },
92+
timestamp: seq,
93+
diff: [],
94+
restorable: false,
95+
};
96+
}
97+
98+
function fakeEventClient(events: NgrxLogEntry[]): DevframeRpcClient {
99+
const ep = eventPage(events);
100+
return {
101+
connectionMeta: {},
102+
scope: () => ({
103+
rpc: {
104+
call: async (name: string) => (name === 'request-ngrx-action' ? { ok: true } : []),
105+
sharedState: async () => ({
106+
value: () => ({ pages: [ep] }) as NgrxState,
107+
on: () => () => undefined,
108+
}),
109+
},
110+
}),
111+
} as unknown as DevframeRpcClient;
112+
}
113+
114+
describe('StoreInspector events', () => {
115+
it('shows events in a dedicated section and selects one on click', async () => {
116+
const fixture = TestBed.createComponent(StoreInspector);
117+
document.body.appendChild(fixture.nativeElement);
118+
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1), eventEntry(2)]));
119+
await fixture.whenStable();
120+
fixture.detectChanges();
121+
122+
const host = fixture.nativeElement as HTMLElement;
123+
const evtButtons = host.querySelectorAll<HTMLButtonElement>('.event-item');
124+
expect(evtButtons.length).toBe(2);
125+
expect(host.querySelector('.event-detail-focus')).toBeNull();
126+
127+
evtButtons[0].click();
128+
await fixture.whenStable();
129+
fixture.detectChanges();
130+
expect(host.querySelector('.event-detail-focus')).not.toBeNull();
131+
expect(evtButtons[0].getAttribute('aria-pressed')).toBe('true');
132+
expect(evtButtons[1].getAttribute('aria-pressed')).toBe('false');
133+
});
134+
135+
it('moves focus to the event detail panel when an event is clicked', async () => {
136+
const fixture = TestBed.createComponent(StoreInspector);
137+
document.body.appendChild(fixture.nativeElement);
138+
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)]));
139+
await fixture.whenStable();
140+
fixture.detectChanges();
141+
142+
const host = fixture.nativeElement as HTMLElement;
143+
host.querySelector<HTMLButtonElement>('.event-item')!.click();
144+
await fixture.whenStable();
145+
fixture.detectChanges();
146+
expect(document.activeElement).toBe(host.querySelector('.event-detail-focus'));
147+
});
148+
149+
it('does not steal focus from the filter when typing after an event is selected', async () => {
150+
const fixture = TestBed.createComponent(StoreInspector);
151+
document.body.appendChild(fixture.nativeElement);
152+
fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)]));
153+
await fixture.whenStable();
154+
fixture.detectChanges();
155+
156+
const host = fixture.nativeElement as HTMLElement;
157+
host.querySelector<HTMLButtonElement>('.event-item')!.click();
158+
await fixture.whenStable();
159+
fixture.detectChanges();
160+
161+
// Now type in the filter — this causes a re-render but must not steal focus
162+
const input = host.querySelector<HTMLInputElement>('input[type="search"]')!;
163+
input.focus();
164+
input.value = 'book';
165+
input.dispatchEvent(new Event('input'));
166+
await fixture.whenStable();
167+
fixture.detectChanges();
168+
expect(document.activeElement).toBe(input);
169+
});
170+
});
171+
72172
describe('StoreInspector restore focus', () => {
73173
it('focuses the state when restoring the newest action does not pause the store', async () => {
74174
const host = await restoreNewest({ ok: true, paused: false, message: 'Jumped.' });

‎app/src/__tests__/store-inspector.test.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing';
22
import type { DevframeRpcClient } from 'devframe/client';
33
import { afterEach, describe, expect, it } from 'vitest';
44
import { StoreInspector } from '../pages/store-inspector';
5-
import type { NgrxLogEntry, NgrxPage, NgrxStoreEntry } from '../pages/store-types';
5+
import type {
6+
NgrxLogEntry,
7+
NgrxPage,
8+
NgrxSignalStoreInfo,
9+
NgrxStoreEntry,
10+
} from '../pages/store-types';
611

712
type Call = (name: string, arg?: Record<string, unknown>) => Promise<unknown>;
813

@@ -190,3 +195,88 @@ describe('StoreInspector restore', () => {
190195
expect(root(fixture).textContent).toContain('actionsBlocklist');
191196
});
192197
});
198+
199+
describe('StoreInspector signal store', () => {
200+
function signalStore(overrides: Partial<NgrxSignalStoreInfo> = {}): NgrxSignalStoreInfo {
201+
return {
202+
id: 'ngrx-1',
203+
kind: 'signal-store',
204+
className: 'ProductStore',
205+
scope: 'root',
206+
stateKeys: ['count'],
207+
state: { count: 0 },
208+
computed: {},
209+
methods: [],
210+
references: [],
211+
writable: true,
212+
...overrides,
213+
};
214+
}
215+
216+
function signalPage(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []): NgrxPage {
217+
return { pageId: 'p1', url: '/', title: 'App', stores, classic: null, log, reportedAt: 0 };
218+
}
219+
220+
async function mountSignal(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []) {
221+
const fixture = TestBed.createComponent(StoreInspector);
222+
fixture.componentRef.setInput(
223+
'rpc',
224+
fakeClient(
225+
(name) => (name === 'get-ngrx-store' ? Promise.resolve([]) : Promise.resolve({})),
226+
signalPage(stores, log),
227+
),
228+
);
229+
await settle(fixture);
230+
return fixture;
231+
}
232+
233+
it('shows entity count and ids for a withEntities() store', async () => {
234+
const fixture = await mountSignal([
235+
signalStore({
236+
stateKeys: ['entityMap', 'ids'],
237+
state: { entityMap: {}, ids: [] },
238+
entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids: ['a', 'b', 'c'], count: 3 }],
239+
}),
240+
]);
241+
const host = root(fixture);
242+
expect(host.textContent).toMatch(/3\s+entities/);
243+
expect(host.querySelector('.entity-ids')).not.toBeNull();
244+
const chips = Array.from(host.querySelectorAll<HTMLElement>('.entity-ids .chip'));
245+
expect(chips.map((c) => c.textContent?.trim())).toEqual(['a', 'b', 'c']);
246+
});
247+
248+
it('truncates ids past 30 and shows a +N more chip', async () => {
249+
const ids = Array.from({ length: 32 }, (_, i) => String(i));
250+
const fixture = await mountSignal([
251+
signalStore({
252+
stateKeys: ['entityMap', 'ids'],
253+
state: { entityMap: {}, ids: [] },
254+
entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids, count: ids.length }],
255+
}),
256+
]);
257+
const host = root(fixture);
258+
const more = host.querySelector<HTMLElement>('.chip.more');
259+
expect(more).not.toBeNull();
260+
expect(more?.textContent?.trim()).toBe('+2 more');
261+
});
262+
263+
it('shows avg and last duration for timed methods and omits them for untimed ones', async () => {
264+
const fixture = await mountSignal([
265+
signalStore({
266+
methods: [
267+
{ name: 'load', calls: 4, avgDurationMs: 10, lastDurationMs: 8 },
268+
{ name: 'reset', calls: 1 },
269+
],
270+
}),
271+
]);
272+
const host = root(fixture);
273+
const methods = host.querySelector('.methods');
274+
expect(methods?.textContent).toContain('avg 10ms');
275+
expect(methods?.textContent).toContain('last 8ms');
276+
// reset has no duration numbers
277+
const resetChip = Array.from(host.querySelectorAll<HTMLElement>('.chip.mono')).find((c) =>
278+
c.textContent?.includes('reset'),
279+
);
280+
expect(resetChip?.textContent).not.toContain('ms');
281+
});
282+
});

0 commit comments

Comments
 (0)