Skip to content

Commit 7fe52db

Browse files
authored
Merge pull request #61 from vineethwilson15/feat/effect-error-details
feat(effects): surface error message and stack trace in effects panel
2 parents 0058cb8 + 2a3b064 commit 7fe52db

7 files changed

Lines changed: 157 additions & 3 deletions

File tree

projects/ngrx-devtool-ui/src/components/effects-panel/effects-panel.component.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ <h3><mat-icon>bolt</mat-icon> Registered Effects</h3>
8080
<span class="detail-label">Time:</span>
8181
<span class="detail-value">{{ execution.endTime | date:'medium' }}</span>
8282
</div>
83+
@if (execution.status === 'error' && execution.errorMessage) {
84+
<div class="detail-row error-message-row">
85+
<span class="detail-label">Error:</span>
86+
<span class="detail-value error-message-text">{{ execution.errorMessage }}</span>
87+
</div>
88+
@if (execution.errorStack) {
89+
<details class="stack-trace">
90+
<summary>Stack Trace</summary>
91+
<pre class="stack-trace-content">{{ execution.errorStack }}</pre>
92+
</details>
93+
}
94+
}
8395
</div>
8496
</mat-expansion-panel>
8597
}

projects/ngrx-devtool-ui/src/components/effects-panel/effects-panel.component.scss

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,43 @@ mat-panel-title {
210210
}
211211
}
212212

213+
// Error details
214+
.error-message-row {
215+
align-items: flex-start;
216+
217+
.error-message-text {
218+
color: $effect-error;
219+
font-weight: 500;
220+
word-break: break-word;
221+
}
222+
}
223+
224+
.stack-trace {
225+
margin-top: $spacing-md;
226+
227+
summary {
228+
cursor: pointer;
229+
color: $color-on-surface-variant;
230+
font-size: 0.85rem;
231+
font-weight: 500;
232+
padding: $spacing-xs 0;
233+
user-select: none;
234+
}
235+
236+
.stack-trace-content {
237+
margin-top: $spacing-sm;
238+
padding: $spacing-md;
239+
background: $color-surface-container;
240+
border-radius: $corner-extra-small;
241+
font-size: 0.78rem;
242+
line-height: 1.5;
243+
color: $color-on-surface-variant;
244+
overflow-x: auto;
245+
white-space: pre-wrap;
246+
word-break: break-word;
247+
}
248+
}
249+
213250
// Lifecycle events
214251
.lifecycle-events {
215252
padding: $spacing-lg;

projects/ngrx-devtool-ui/src/components/effects-panel/effects-panel.component.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ export class EffectsPanelComponent {
7171
const lifecycleProps = {
7272
emitted: { status: 'completed' as const, emittedAction: event.action, dispatch: true },
7373
executed: { status: 'executed' as const, dispatch: false },
74-
error: { status: 'error' as const },
74+
error: {
75+
status: 'error' as const,
76+
errorMessage: event.effectEvent.errorMessage,
77+
errorStack: event.effectEvent.errorStack,
78+
},
7579
};
7680

7781
completedExecutions.push({

projects/ngrx-devtool-ui/src/components/effects-panel/effects-panel.models.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface EffectEventMessage {
88
readonly duration?: number;
99
readonly executionId?: string;
1010
readonly dispatch?: boolean;
11+
readonly errorMessage?: string;
12+
readonly errorStack?: string;
1113
};
1214
readonly timestamp: string;
1315
}
@@ -24,4 +26,6 @@ export interface EffectExecution {
2426
readonly emittedAction?: string;
2527
readonly executionId?: string;
2628
readonly dispatch?: boolean;
29+
readonly errorMessage?: string;
30+
readonly errorStack?: string;
2731
}

projects/ngrx-devtool/src/lib/core/actions-interceptor.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export class ActionsInterceptorService implements OnDestroy {
8181
this.effectTracker.effectEvents$.pipe(
8282
takeUntil(this.destroy$),
8383
tap((event: EffectEvent) => {
84+
const errorMessage = event.error instanceof Error
85+
? event.error.message
86+
: event.error != null ? String(event.error) : undefined;
87+
const errorStack = event.error instanceof Error ? event.error.stack : undefined;
88+
8489
const message: DevToolMessage = {
8590
type: 'EFFECT_EVENT',
8691
action: event.action?.type,
@@ -91,6 +96,8 @@ export class ActionsInterceptorService implements OnDestroy {
9196
duration: event.duration,
9297
executionId: event.executionId,
9398
dispatch: event.dispatch,
99+
errorMessage,
100+
errorStack,
94101
},
95102
timestamp: new Date().toISOString(),
96103
};

projects/ngrx-devtool/src/lib/core/core.models.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,6 @@ export interface DevToolEffectEventPayload {
7777
readonly duration?: number;
7878
readonly executionId?: string;
7979
readonly dispatch?: boolean;
80+
readonly errorMessage?: string;
81+
readonly errorStack?: string;
8082
}

projects/ngrx-devtool/src/lib/testing/actions-interceptor.service.spec.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { TestBed } from '@angular/core/testing';
1+
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
22
import { PLATFORM_ID } from '@angular/core';
33
import { provideStore } from '@ngrx/store';
44
import { provideEffects, EffectSources } from '@ngrx/effects';
55

66
import { ActionsInterceptorService, DevToolMessage } from '../core/actions-interceptor.service';
77
import { EffectTrackerService } from '../core/effect-tracker.service';
8-
import { DevToolsEffectSources } from '../core/devtools-effect-sources';
8+
import { DevToolsEffectSources, EffectEvent } from '../core/devtools-effect-sources';
99
import { WebSocketService } from '../core/websocket.service';
1010
import { TestEffects, testActions, testReducer } from './test-effects';
1111
import { MockWebSocket, WebSocketConstants } from './mock-websocket';
@@ -172,6 +172,94 @@ describe('ActionsInterceptorService', () => {
172172
});
173173
});
174174

175+
describe('Effect Event Forwarding', () => {
176+
function getEffectSources(): DevToolsEffectSources {
177+
return TestBed.inject(EffectSources) as DevToolsEffectSources;
178+
}
179+
180+
function emitEffectEvent(event: EffectEvent): void {
181+
getEffectSources().effectEvents$.next(event);
182+
}
183+
184+
it('should include errorMessage and errorStack in EFFECT_EVENT when lifecycle is error', fakeAsync(() => {
185+
interceptorService.initialize();
186+
const ws = getCreatedWebSocket();
187+
ws.simulateOpen();
188+
ws.clearMessages();
189+
190+
const error = new Error('Something went wrong');
191+
192+
emitEffectEvent({
193+
effectName: 'TestEffects.failingEffect$',
194+
sourceName: 'TestEffects',
195+
propertyName: 'failingEffect$',
196+
lifecycle: 'error',
197+
error,
198+
timestamp: Date.now(),
199+
executionId: 'test-exec-error-1',
200+
});
201+
tick(0);
202+
203+
const messages = ws.getSentMessagesAsObjects<DevToolMessage>();
204+
const effectMsg = messages.find((m) => m.type === 'EFFECT_EVENT');
205+
206+
expect(effectMsg).toBeDefined();
207+
expect(effectMsg!.effectEvent?.errorMessage).toBe('Something went wrong');
208+
expect(effectMsg!.effectEvent?.errorStack).toContain('Error: Something went wrong');
209+
}));
210+
211+
it('should include errorMessage for non-Error thrown values', fakeAsync(() => {
212+
interceptorService.initialize();
213+
const ws = getCreatedWebSocket();
214+
ws.simulateOpen();
215+
ws.clearMessages();
216+
217+
emitEffectEvent({
218+
effectName: 'TestEffects.failingEffect$',
219+
sourceName: 'TestEffects',
220+
propertyName: 'failingEffect$',
221+
lifecycle: 'error',
222+
error: 'plain string error',
223+
timestamp: Date.now(),
224+
executionId: 'test-exec-error-2',
225+
});
226+
tick(0);
227+
228+
const messages = ws.getSentMessagesAsObjects<DevToolMessage>();
229+
const effectMsg = messages.find((m) => m.type === 'EFFECT_EVENT');
230+
231+
expect(effectMsg).toBeDefined();
232+
expect(effectMsg!.effectEvent?.errorMessage).toBe('plain string error');
233+
expect(effectMsg!.effectEvent?.errorStack).toBeUndefined();
234+
}));
235+
236+
it('should not include errorMessage or errorStack for non-error lifecycle', fakeAsync(() => {
237+
interceptorService.initialize();
238+
const ws = getCreatedWebSocket();
239+
ws.simulateOpen();
240+
ws.clearMessages();
241+
242+
emitEffectEvent({
243+
effectName: 'TestEffects.loadItems$',
244+
sourceName: 'TestEffects',
245+
propertyName: 'loadItems$',
246+
lifecycle: 'emitted',
247+
action: testActions.loadItemsSuccess({ items: [] }),
248+
timestamp: Date.now(),
249+
executionId: 'test-exec-emitted-1',
250+
dispatch: true,
251+
});
252+
tick(0);
253+
254+
const messages = ws.getSentMessagesAsObjects<DevToolMessage>();
255+
const effectMsg = messages.find((m) => m.type === 'EFFECT_EVENT');
256+
257+
expect(effectMsg).toBeDefined();
258+
expect(effectMsg!.effectEvent?.errorMessage).toBeUndefined();
259+
expect(effectMsg!.effectEvent?.errorStack).toBeUndefined();
260+
}));
261+
});
262+
175263
describe('Platform Detection', () => {
176264
it('should not create WebSocket on non-browser platform', () => {
177265
TestBed.resetTestingModule();

0 commit comments

Comments
 (0)