Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions dashboard/src/components/EventCard.unknown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { EventCard } from './EventCard';
import type { BlockchainEvent } from '../types/event';

const UNKNOWN_TYPE = 'FutureContractEvent';

function unknownEvent(overrides: Partial<BlockchainEvent> = {}): BlockchainEvent {
return {
eventId: 'evt-unknown-1',
contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12',
eventName: UNKNOWN_TYPE,
ledger: 12345,
type: UNKNOWN_TYPE,
topic: [UNKNOWN_TYPE],
value: '{"weird_field":42}',
receivedAt: Date.now(),
...overrides,
} as BlockchainEvent;
}

describe('EventCard unknown event type fallback (issue #612)', () => {
it('renders an unknown event with the neutral fallback badge instead of throwing', () => {
const { container } = render(<EventCard event={unknownEvent()} />);

const card = container.querySelector('.event-card--compact');
expect(card).toBeTruthy();
expect(card!.getAttribute('data-event-type-known')).toBe('false');
expect(container.querySelector('.event-card__badge--default')).toBeTruthy();
});

it('stays inspectable when the payload is not a plain string', () => {
const event = unknownEvent({
value: { weird_field: 42 } as unknown as string,
topic: undefined as unknown as string[],
});

expect(() => render(<EventCard event={event} variant="expanded" />)).not.toThrow();
expect(screen.getByText(/Unrecognized event type/i)).toBeInTheDocument();
expect(document.querySelector('.event-card__payload')).toHaveTextContent(/weird_field/);
});

it('keeps known events on their mapped presentation', () => {
const { container } = render(
<EventCard event={unknownEvent({ eventName: 'TaskCreated', type: 'contract' })} />,
);

const card = container.querySelector('.event-card--compact');
expect(card!.getAttribute('data-event-type-known')).toBe('true');
expect(container.querySelector('.event-card__badge--green')).toBeTruthy();
});
});
48 changes: 28 additions & 20 deletions dashboard/src/components/EventExplorerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { memo, useMemo } from 'react';
import type { BlockchainEvent } from '../types/event';
import type { ContractStatus } from '../services/eventsApi';
import { formatTimestamp, parseToDate } from '../utils/formatTime';
import { formatTimestamp } from '../utils/formatTime';
import { getEventKindClass, getEventKindLabel, isKnownEventType } from '../utils/eventTypeMapping';
import { CopyButton } from './CopyButton';

import { getEventKindClass, getEventKindLabel } from '../utils/eventTypeMapping';

function shortenAddress(address: string) {
if (address.length <= 14) {
return address;
Expand All @@ -20,7 +17,6 @@ interface EventExplorerCardProps {
onCopyContract: (contractAddress: string) => void;
isCopied: boolean;
onSelect?: (event: BlockchainEvent) => void;
contractStatuses: ContractStatus[];
contractStatuses?: ContractStatus[];
}

Expand All @@ -36,14 +32,15 @@ export function EventExplorerCard({
const label = event.eventName ?? event.type;
const badgeClass = getEventKindClass(event.type);
const kindLabel = getEventKindLabel(event.type);
const receivedAt = parseToDate(event.receivedAt);
const isUnknown = !isKnownEventType(event.type);

return (
<article
className={`event-explorer__row${onSelect ? ' event-card--clickable' : ''}`}
role={onSelect ? 'button' : 'row'}
tabIndex={onSelect ? 0 : undefined}
data-event-id={event.eventId}
data-event-type-known={isUnknown ? 'false' : 'true'}
onClick={onSelect ? () => onSelect(event) : undefined}
onKeyDown={
onSelect
Expand All @@ -60,16 +57,24 @@ export function EventExplorerCard({
<div className="event-explorer__cell" data-label="Contract" role="cell">
<div className="event-explorer__contract-block">
<p className="event-explorer__contract" title={event.contractAddress}>
{shortenedContract}
{shortenAddress(event.contractAddress)}
</p>
<button
type="button"
className="event-explorer__copy-button"
onClick={handleCopyClick}
aria-label={`Copy contract address ${event.contractAddress}`}
>
{isCopied ? 'Copied' : 'Copy'}
</button>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="event-explorer__copy-button"
onClick={(e) => {
e.stopPropagation();
onCopyContract(event.contractAddress);
}}
aria-label={`Copy contract address ${event.contractAddress}`}
>
{isCopied ? 'Copied' : 'Copy'}
</button>
{isPaused && (
<span className="event-explorer__badge event-explorer__badge--paused">Paused</span>
)}
</div>
</div>
</div>

Expand All @@ -78,14 +83,17 @@ export function EventExplorerCard({
</div>

<div className="event-explorer__cell" data-label="Kind" role="cell">
<span className={`event-explorer__badge ${badgeClass}`}>{kindLabel}</span>
<span
className={`event-explorer__badge ${badgeClass}`}
title={isUnknown ? `Unrecognized event kind: ${event.type}` : undefined}
>
{kindLabel}
</span>
</div>

<div className="event-explorer__cell" data-label="Received" role="cell">
<time dateTime={receivedAt?.toISOString()}>
{formatTimestamp(event.receivedAt)}
<time dateTime={new Date(event.receivedAt).toISOString()}>
{formattedTime}
{formatTimestamp(event.receivedAt)}
</time>
</div>

Expand All @@ -108,4 +116,4 @@ export function EventExplorerCard({
</div>
</article>
);
});
}
72 changes: 72 additions & 0 deletions dashboard/src/utils/eventTypeMapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
EVENT_TYPE_PRESENTATIONS,
UNKNOWN_EVENT_TYPE_PRESENTATION,
getEventBadgeClass,
getEventKindClass,
getEventKindLabel,
getEventTypePresentation,
isKnownEventType,
} from './eventTypeMapping';

describe('isKnownEventType (issue #612)', () => {
it('returns true for catalogued event names', () => {
expect(isKnownEventType('TaskCreated')).toBe(true);
expect(isKnownEventType('NotificationScheduled')).toBe(true);
});

it('matches catalogued event names case-insensitively', () => {
expect(isKnownEventType('taskcreated')).toBe(true);
expect(isKnownEventType('CONTRACT')).toBe(true);
});

it('returns false for event types the dashboard has not seen yet', () => {
expect(isKnownEventType('FutureContractEvent')).toBe(false);
expect(isKnownEventType('reputation_updated')).toBe(false);
});

it('returns false for empty, null and undefined input', () => {
expect(isKnownEventType('')).toBe(false);
expect(isKnownEventType(null)).toBe(false);
expect(isKnownEventType(undefined)).toBe(false);
});
});

describe('getEventTypePresentation fallback for unknown event types (issue #612)', () => {
it('returns the neutral fallback presentation for an unmapped type', () => {
const presentation = getEventTypePresentation('FutureContractEvent');

expect(presentation.badgeClass).toBe(UNKNOWN_EVENT_TYPE_PRESENTATION.badgeClass);
expect(presentation.kindBadgeClass).toBe(UNKNOWN_EVENT_TYPE_PRESENTATION.kindBadgeClass);
expect(presentation.kindLabel).toBe('Unknown');
expect(presentation.category).toBe('General');
expect(presentation.color).toBe('gray');
});

it('keeps the raw event type as the label so the payload stays identifiable', () => {
expect(getEventTypePresentation('FutureContractEvent').label).toBe('FutureContractEvent');
});

it('never returns undefined presentation fields for an unknown type', () => {
const presentation = getEventTypePresentation('brand_new_event');

for (const value of Object.values(presentation)) {
expect(value).toBeDefined();
}
});

it('still resolves catalogued types exactly', () => {
const known = getEventTypePresentation('TaskCreated');

expect(known).toBe(EVENT_TYPE_PRESENTATIONS.TaskCreated);
});

it('helpers fall back safely for unknown types', () => {
expect(getEventBadgeClass('FutureContractEvent')).toBe(
UNKNOWN_EVENT_TYPE_PRESENTATION.badgeClass,
);
expect(getEventKindClass('FutureContractEvent')).toBe(
UNKNOWN_EVENT_TYPE_PRESENTATION.kindBadgeClass,
);
expect(getEventKindLabel('FutureContractEvent')).toBe('Unknown');
});
});
45 changes: 37 additions & 8 deletions dashboard/src/utils/eventTypeMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,10 @@ export const EVENT_TYPE_PRESENTATIONS: Record<string, EventTypePresentation> = {
};

/**
* Returns centralized presentation metadata for any given event type or event name.
* Uses a safe fallback if the event type is unsupported or missing.
* Looks up a catalogued presentation, matching the key case-insensitively.
* Returns `undefined` when the event type is not part of the known catalog.
*/
export function getEventTypePresentation(eventType?: string | null): EventTypePresentation {
if (!eventType) {
return UNKNOWN_EVENT_TYPE_PRESENTATION;
}

function findKnownPresentation(eventType: string): EventTypePresentation | undefined {
// Exact match first
if (EVENT_TYPE_PRESENTATIONS[eventType]) {
return EVENT_TYPE_PRESENTATIONS[eventType];
Expand All @@ -288,7 +284,40 @@ export function getEventTypePresentation(eventType?: string | null): EventTypePr
}
}

// Safe fallback
return undefined;
}

/**
* Returns true only when the event type has a dedicated presentation mapping.
*
* Unknown event types (new contracts, or a listener that is ahead of the
* dashboard build) resolve to `false` so callers can render an explicit
* fallback that keeps the raw payload inspectable (issue #612).
*/
export function isKnownEventType(eventType?: string | null): boolean {
if (!eventType) {
return false;
}

return findKnownPresentation(eventType) !== undefined;
}

/**
* Returns centralized presentation metadata for any given event type or event name.
* Uses a safe fallback if the event type is unsupported or missing.
*/
export function getEventTypePresentation(eventType?: string | null): EventTypePresentation {
if (!eventType) {
return UNKNOWN_EVENT_TYPE_PRESENTATION;
}

const known = findKnownPresentation(eventType);
if (known) {
return known;
}

// Safe fallback: keep the raw event type as the label so an unknown event
// remains identifiable while the badge falls back to neutral styling.
return {
...UNKNOWN_EVENT_TYPE_PRESENTATION,
label: eventType,
Expand Down
110 changes: 110 additions & 0 deletions dashboard/src/utils/payloadViewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { formatPayloadPreview, formatRawPayload, sanitizePayload } from './payloadViewer';

describe('formatRawPayload with unknown payload shapes (issue #612)', () => {
it('pretty-prints object payloads the dashboard has never seen', () => {
const result = formatRawPayload({ weird_field: 42, nested: { ok: true } });

expect(result.isValidJson).toBe(true);
expect(result.formatted).toContain('"weird_field": 42');
expect(() => JSON.parse(result.formatted)).not.toThrow();
});

it('pretty-prints JSON encoded as a string', () => {
const result = formatRawPayload('{"weird_field":42}');

expect(result.isValidJson).toBe(true);
expect(JSON.parse(result.formatted)).toEqual({ weird_field: 42 });
});

it('returns plain text for non-JSON payloads without throwing', () => {
const result = formatRawPayload('not-json-at-all');

expect(result.isValidJson).toBe(false);
expect(result.formatted).toBe('not-json-at-all');
});

it('does not throw on circular payloads and stays stringifiable', () => {
const circular: Record<string, unknown> = { name: 'loop' };
circular.self = circular;

let result: ReturnType<typeof formatRawPayload> | undefined;
expect(() => {
result = formatRawPayload(circular);
}).not.toThrow();

expect(result!.formatted).toContain('loop');
expect(result!.formatted).toContain('[Circular]');
});

it('does not throw on values JSON cannot serialise', () => {
expect(() => formatRawPayload({ big: BigInt(1) })).not.toThrow();
});

it('handles null and undefined payloads', () => {
expect(formatRawPayload(null).formatted).toBe('null');
expect(formatRawPayload(undefined).formatted).toBe('null');
});
});

describe('sanitizePayload circular guard', () => {
it('replaces circular references instead of recursing forever', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;

const sanitized = sanitizePayload(circular) as Record<string, unknown>;

expect(sanitized.self).toBe('[Circular]');
});

it('keeps repeated non-circular references in sibling fields', () => {
const shared = { id: 1 };

const sanitized = sanitizePayload({ a: shared, b: shared }) as Record<string, unknown>;

expect(sanitized.a).toEqual({ id: 1 });
expect(sanitized.b).toEqual({ id: 1 });
});
});

describe('formatPayloadPreview (issue #612)', () => {
it('renders object payloads as a single inspectable line', () => {
const { preview, full, truncated } = formatPayloadPreview({ weird_field: 42 });

expect(preview).toBe('{ "weird_field": 42 }');
expect(preview).not.toContain('\n');
expect(full).toContain('"weird_field": 42');
expect(truncated).toBe(false);
});

it('truncates long payloads for the inline preview but keeps the full payload', () => {
const longValue = 'x'.repeat(500);
const { preview, full, truncated } = formatPayloadPreview(longValue, 20);

expect(truncated).toBe(true);
expect(preview.length).toBe(20);
expect(preview.endsWith('…')).toBe(true);
expect(full).toBe(longValue);
});

it('never throws and always returns a string for arbitrary values', () => {
const values: unknown[] = [
undefined,
null,
0,
'',
[],
{},
['a', { b: 1 }],
'plain text',
];

for (const value of values) {
let result: ReturnType<typeof formatPayloadPreview> | undefined;
expect(() => {
result = formatPayloadPreview(value);
}).not.toThrow();
expect(typeof result!.preview).toBe('string');
expect(result!.preview.length).toBeGreaterThan(0);
}
});
});
Loading