Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,44 @@
* limitations under the License.
*/

import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const registerHeadlampEventCallback = vi.fn();
const setEvent = vi.fn();
const useGlobalState = vi.fn(() => ({ event: null, setEvent }));

vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
registerHeadlampEventCallback: (callback: unknown) => registerHeadlampEventCallback(callback),
}));

vi.mock('../../pluginState', () => ({
useGlobalState: () => ({ event: null, setEvent }),
useGlobalState,
}));

/** Renders the handler and returns the callback it registered. */
async function getEventCallback() {
const { default: HeadlampEventHandler } = await import('./HeadlampEventHandler');
HeadlampEventHandler();
render(<HeadlampEventHandler />);
await waitFor(() => expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1));
return registerHeadlampEventCallback.mock.calls.at(-1)![0] as (event: {
type: string;
data?: unknown;
}) => null;
}) => void;
}

const project = { id: 'my-project', namespaces: ['dev'], clusters: ['minikube'] };

describe('HeadlampEventHandler', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
useGlobalState.mockImplementation(() => ({ event: null, setEvent }));
});

afterEach(() => {
vi.restoreAllMocks();
});

it('stores the project list from the project list view event', async () => {
Expand All @@ -57,6 +66,83 @@ describe('HeadlampEventHandler', () => {
});
});

it('keeps one active callback through StrictMode rerenders and removes it on unmount', async () => {
const { default: HeadlampEventHandler } = await import('./HeadlampEventHandler');
const { rerender, unmount } = render(
<React.StrictMode>
<HeadlampEventHandler />
</React.StrictMode>
);
await waitFor(() => expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1));
const callback = registerHeadlampEventCallback.mock.calls[0][0] as (event: {
type: string;
data?: unknown;
}) => void;

rerender(
<React.StrictMode>
<HeadlampEventHandler />
</React.StrictMode>
);
callback({ type: 'headlamp.project-list-view', data: { projects: [project] } });

expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1);
expect(setEvent).toHaveBeenCalledTimes(1);

unmount();
setEvent.mockClear();
callback({ type: 'headlamp.project-list-view', data: { projects: [project] } });

expect(setEvent).not.toHaveBeenCalled();
});

it('dispatches to a snapshot when a subscriber unmounts another subscriber', async () => {
const firstSetEvent = vi.fn();
const secondSetEvent = vi.fn();
useGlobalState
.mockReturnValueOnce({ event: null, setEvent: firstSetEvent })
.mockReturnValueOnce({ event: null, setEvent: secondSetEvent });
const { default: HeadlampEventHandler } = await import('./HeadlampEventHandler');
render(<HeadlampEventHandler />);
const secondHandler = render(<HeadlampEventHandler />);
await waitFor(() => expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1));
firstSetEvent.mockImplementation(() => secondHandler.unmount());
const callback = registerHeadlampEventCallback.mock.calls[0][0] as (event: {
type: string;
data?: unknown;
}) => void;

callback({ type: 'headlamp.project-list-view', data: { projects: [project] } });

expect(firstSetEvent).toHaveBeenCalledTimes(1);
expect(secondSetEvent).toHaveBeenCalledTimes(1);
});

it('continues dispatching when a subscriber throws', async () => {
const error = new Error('subscriber failed');
const firstSetEvent = vi.fn(() => {
throw error;
});
const secondSetEvent = vi.fn();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
useGlobalState
.mockReturnValueOnce({ event: null, setEvent: firstSetEvent })
.mockReturnValueOnce({ event: null, setEvent: secondSetEvent });
const { default: HeadlampEventHandler } = await import('./HeadlampEventHandler');
render(<HeadlampEventHandler />);
render(<HeadlampEventHandler />);
await waitFor(() => expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1));
const callback = registerHeadlampEventCallback.mock.calls[0][0] as (event: {
type: string;
data?: unknown;
}) => void;

callback({ type: 'headlamp.project-list-view', data: { projects: [project] } });

expect(secondSetEvent).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith('Failed to handle Headlamp event:', error);
});

it('defaults to an empty project list when none are provided', async () => {
const callback = await getEventCallback();

Expand Down
208 changes: 120 additions & 88 deletions ai-assistant/src/components/appbar/HeadlampEventHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registerHeadlampEventCallback } from '@kinvolk/headlamp-plugin/lib';
import { useEffect, useRef } from 'react';
import type { HeadlampEventPayload, ProjectSummary } from '../../pluginState';
import { useGlobalState } from '../../pluginState';

Expand All @@ -13,6 +14,32 @@ const HEADLAMP_EVENT_TYPE = {
DELETE_PROJECT: 'headlamp.delete-project',
} as const;

type HeadlampEventCallback = Parameters<typeof registerHeadlampEventCallback>[0];

const headlampEventSubscribers = new Set<HeadlampEventCallback>();
let isHeadlampEventDispatcherRegistered = false;

function subscribeToHeadlampEvents(callback: HeadlampEventCallback): () => void {
headlampEventSubscribers.add(callback);

if (!isHeadlampEventDispatcherRegistered) {
registerHeadlampEventCallback(event => {
for (const subscriber of [...headlampEventSubscribers]) {
try {
subscriber(event);
} catch (error) {
console.error('Failed to handle Headlamp event:', error);
}
}
});
isHeadlampEventDispatcherRegistered = true;
}

return () => {
headlampEventSubscribers.delete(callback);
};
}

/** Tab labels can be React nodes, so fall back to the serializable tab id. */
function projectTabName(tab: unknown): string | undefined {
const { id, label } = (tab ?? {}) as { id?: string; label?: unknown };
Expand All @@ -36,95 +63,100 @@ function projectTitle(project: ProjectSummary | undefined): string {
*/
export default function HeadlampEventHandler() {
const _pluginState = useGlobalState();
const pluginStateRef = useRef(_pluginState);
pluginStateRef.current = _pluginState;

useEffect(() => {
return subscribeToHeadlampEvents(event => {
const pluginState = pluginStateRef.current;
// Headlamp's event.data type does not expose all fields present at runtime.
// Cast once here rather than scattering per-field `as any` assertions.
const data = (event.data ?? {}) as Record<string, unknown>;
const prev = (pluginState?.event ?? {}) as Record<string, unknown>;

registerHeadlampEventCallback(event => {
// Headlamp's event.data type does not expose all fields present at runtime.
// Cast once here rather than scattering per-field `as any` assertions.
const data = (event.data ?? {}) as Record<string, unknown>;
const prev = (_pluginState?.event ?? {}) as Record<string, unknown>;
if (event.type === 'headlamp.home-page-loaded') {
pluginState.setEvent({
...pluginState.event,
type: 'headlamp.home-page-loaded',
clusters: data.clusters,
errors: data.errors,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.OBJECT_EVENTS) {
pluginState.setEvent({
...pluginState.event,
type: HEADLAMP_EVENT_TYPE.OBJECT_EVENTS,
objectEvent: prev.objectEvent,
resources: data.resources,
resourceKind: data.resourceKind,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.DETAILS_VIEW) {
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.DETAILS_VIEW,
title: data.title,
resource: data.resource,
objectEvent: prev.objectEvent,
resources: data.resources,
resourceKind: data.resourceKind,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.LIST_VIEW) {
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.LIST_VIEW,
title: data.title,
resources: data.resources,
resourceKind: data.resourceKind,
resource: data.resource,
objectEvent: prev.objectEvent,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_LIST_VIEW) {
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_LIST_VIEW,
title: 'Projects',
projects: (data.projects ?? []) as ProjectSummary[],
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_VIEW) {
const project = data.project as ProjectSummary | undefined;
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_VIEW,
title: projectTitle(project),
project,
resources: data.resources,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_TAB_CHANGE) {
const project = data.project as ProjectSummary | undefined;
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_TAB_CHANGE,
title: projectTitle(project),
project,
projectTab: projectTabName(data.tab),
previousProjectTab: projectTabName(data.previousTab),
resources: data.resources,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.CREATE_PROJECT) {
const project = data.project as ProjectSummary | undefined;
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.CREATE_PROJECT,
title: projectTitle(project),
project,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.DELETE_PROJECT) {
const project = data.project as ProjectSummary | undefined;
pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.DELETE_PROJECT,
title: projectTitle(project),
project,
deleteNamespaces: data.deleteNamespaces as boolean | undefined,
} as HeadlampEventPayload);
}
});
}, []);
Comment thread
illume marked this conversation as resolved.

if (event.type === 'headlamp.home-page-loaded') {
_pluginState.setEvent({
..._pluginState.event,
type: 'headlamp.home-page-loaded',
clusters: data.clusters,
errors: data.errors,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.OBJECT_EVENTS) {
_pluginState.setEvent({
..._pluginState.event,
type: HEADLAMP_EVENT_TYPE.OBJECT_EVENTS,
objectEvent: prev.objectEvent,
resources: data.resources,
resourceKind: data.resourceKind,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.DETAILS_VIEW) {
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.DETAILS_VIEW,
title: data.title,
resource: data.resource,
objectEvent: prev.objectEvent,
resources: data.resources,
resourceKind: data.resourceKind,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.LIST_VIEW) {
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.LIST_VIEW,
title: data.title,
resources: data.resources,
resourceKind: data.resourceKind,
resource: data.resource,
objectEvent: prev.objectEvent,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_LIST_VIEW) {
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_LIST_VIEW,
title: 'Projects',
projects: (data.projects ?? []) as ProjectSummary[],
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_VIEW) {
const project = data.project as ProjectSummary | undefined;
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_VIEW,
title: projectTitle(project),
project,
resources: data.resources,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_TAB_CHANGE) {
const project = data.project as ProjectSummary | undefined;
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.PROJECT_DETAILS_TAB_CHANGE,
title: projectTitle(project),
project,
projectTab: projectTabName(data.tab),
previousProjectTab: projectTabName(data.previousTab),
resources: data.resources,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.CREATE_PROJECT) {
const project = data.project as ProjectSummary | undefined;
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.CREATE_PROJECT,
title: projectTitle(project),
project,
} as HeadlampEventPayload);
}
if (event.type === HEADLAMP_EVENT_TYPE.DELETE_PROJECT) {
const project = data.project as ProjectSummary | undefined;
_pluginState.setEvent({
type: HEADLAMP_EVENT_TYPE.DELETE_PROJECT,
title: projectTitle(project),
project,
deleteNamespaces: data.deleteNamespaces as boolean | undefined,
} as HeadlampEventPayload);
}
return null;
});
return null;
}
Loading