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
7 changes: 7 additions & 0 deletions ai-assistant/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The scenarios cover:
- changing and persisting preview, Kubernetes tool, and Holmes service settings;
- adding and removing the built-in mock testing model;
- chatting with the fixture-backed model;
- keeping one registered Headlamp event callback across real pod navigation and
assistant rerenders, and keeping pod/deployment suggestion drafts bound to the
current resource after navigation and panel reopening. A test-only SDK app-bar
probe reads the real host callback registry because production React omits
development warnings; it does not replace event registration or delivery.
These scenarios send no model requests and make no Kubernetes writes.
- troubleshooting a pod with the scripted agent;
- exploring cluster workloads with the scripted agent.
- background diagnosis scrolling and compact results at desktop and small-window
Expand Down Expand Up @@ -50,6 +56,7 @@ To run a focused scenario with the same build and cluster setup, pass Playwright

```sh
npm run e2e -- proactive-diagnosis.spec.ts --grep "chat starts during an event read"
npm run e2e -- event-registration.spec.ts
```

The cross-platform TypeScript runner builds the plugin and Headlamp image, creates both KWOK clusters, runs Headlamp and Chromium, and deletes the clusters afterward. Set `KEEP_E2E_CLUSTER=true` to retain them for debugging. Each run writes local, untracked screenshots to `e2e/screenshots`. Pass Playwright arguments after `--` for a focused run, for example `npm run e2e -- editor-cluster.spec.ts`.
Expand Down
176 changes: 176 additions & 0 deletions ai-assistant/e2e/event-registration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { expect, type Page, test } from '@playwright/test';
import type { ReactElement } from 'react';

declare global {
interface Window {
pluginLib: {
React: typeof import('react');
ReactRedux: {
useSelector: (
selector: (state: { eventCallbackReducer: { trackerFuncs: unknown[] } }) => number
) => number;
};
registerAppBarAction: (component: () => ReactElement) => void;
};
}
}

test.use({ viewport: { width: 1440, height: 1000 }, actionTimeout: 15_000 });

async function openAssistant(page: Page) {
await page.getByRole('button', { name: 'AI Assistant', exact: true }).click();
await expect(page.locator('#deployment-ai-prompt')).toBeEnabled();
}

async function closeAssistant(page: Page) {
await page
.getByRole('complementary', { name: 'AI Assistant panel' })
.getByRole('button', { name: 'Close', exact: true })
.click();
}

async function checkPodDraft(page: Page, name: string) {
await page.getByRole('link', { name, exact: true }).first().click();
await page.getByRole('button', { name: 'How can I debug this pod?', exact: true }).click();
await expect(page.locator('#deployment-ai-prompt')).toHaveValue(
`How can I debug pod ${name} in namespace demo?`
);
await page.locator('#deployment-ai-prompt').clear();
}

test.describe('Headlamp navigation event registration', () => {
let browserErrors: string[];
let modelRequests: string[];

test.beforeEach(async ({ page }) => {
browserErrors = [];
modelRequests = [];
page.on('pageerror', error => browserErrors.push(error.message));
page.on('console', message => {
if (/Cannot update a component.*while rendering a different component/.test(message.text())) {
browserErrors.push(message.text());
}
});
page.on('request', request => {
if (request.url().includes('/event-registration-model/')) {
modelRequests.push(request.url());
}
});
await page.addInitScript(() => {
localStorage.setItem(
'pluginConfigs',
JSON.stringify({
'@headlamp-k8s/ai-assistant': {
previewEnabled: true,
proactiveDiagnosisEnabled: false,
configPopoverShown: true,
enabledTools: [],
providers: [
{
providerId: 'vllm',
config: {
baseUrl: `${location.origin}/event-registration-model`,
model: 'navigation-regression',
},
},
],
},
})
);
});
await page.route('**/config', async route => {
const response = await route.fetch();
const config = await response.json();
await route.fulfill({
response,
json: {
...config,
clusters: config.clusters.filter((cluster: { name: string }) => cluster.name === 'main'),
},
});
});
await page.goto('/c/main/pods');
const login = page.getByRole('button', { name: 'Use A Token' });
if (await login.isVisible()) {
const token = process.env.HEADLAMP_TOKEN;
expect(token, 'HEADLAMP_TOKEN is required when authentication is enabled').toBeTruthy();
await login.click();
await page.getByRole('textbox', { name: 'ID token' }).fill(token!);
await page.getByRole('button', { name: 'Authenticate' }).click();
}
await expect(page.getByRole('link', { name: 'checkout-crashloop', exact: true })).toBeVisible();
await openAssistant(page);
await page.getByLabel('Assistant mode').click();
await page.getByRole('option', { name: 'Chat', exact: true }).click();
});

test.afterEach(() => {
expect(
browserErrors,
'Navigation must not cause uncaught errors or render-phase warnings'
).toEqual([]);
expect(modelRequests, 'Reviewing context-specific drafts must not send model requests').toEqual(
[]
);
});

test('keeps one host callback through navigation and assistant rerenders', async ({ page }) => {
// Production React omits development warnings; inspect the real host registry as well.
await page.evaluate(() => {
const host = window.pluginLib;
host.registerAppBarAction(function EventCallbackProbe() {
const count = host.ReactRedux.useSelector(
state => state.eventCallbackReducer.trackerFuncs.length
);
return host.React.createElement(
'span',
{ 'data-testid': 'headlamp-event-callback-count', 'aria-hidden': true },
count
);
});
});
const callbackCount = page.getByTestId('headlamp-event-callback-count');
await expect(callbackCount).toHaveText('1');

for (const name of ['checkout-crashloop', 'inventory-pending', 'checkout-crashloop']) {
await checkPodDraft(page, name);
await expect(callbackCount).toHaveText('1');
await closeAssistant(page);
await openAssistant(page);
await expect(callbackCount).toHaveText('1');
await page.getByRole('button', { name: 'Pods', exact: true }).click();
await expect(
page.getByRole('link', { name: 'checkout-crashloop', exact: true })
).toBeVisible();
await expect(callbackCount).toHaveText('1');
}
});

test('uses the current resource for drafts after navigation and panel reopening', async ({
page,
}) => {
await checkPodDraft(page, 'checkout-crashloop');
await page.getByRole('button', { name: 'Pods', exact: true }).click();
await expect(page.getByRole('link', { name: 'inventory-pending', exact: true })).toBeVisible();
await checkPodDraft(page, 'inventory-pending');
await closeAssistant(page);
await openAssistant(page);
await page.getByRole('button', { name: 'How can I debug this pod?', exact: true }).click();
await expect(page.locator('#deployment-ai-prompt')).toHaveValue(
'How can I debug pod inventory-pending in namespace demo?'
);
await page.locator('#deployment-ai-prompt').clear();

await page.getByRole('button', { name: 'Deployments', exact: true }).click();
await page.getByRole('link', { name: 'web', exact: true }).first().click();
await page
.getByRole('button', { name: 'How can I scale this deployment?', exact: true })
.click();
await expect(page.locator('#deployment-ai-prompt')).toHaveValue(
'How can I scale deployment web in namespace demo?'
);
await expect(
page.getByRole('button', { name: 'How can I debug this pod?', exact: true })
).toBeHidden();
});
});
103 changes: 99 additions & 4 deletions ai-assistant/src/components/appbar/HeadlampEventHandler.test.tsx
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,92 @@ describe('HeadlampEventHandler', () => {
});
});

it('uses the latest setter through StrictMode rerenders without registering again and stops 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;

const nextSetEvent = vi.fn();
useGlobalState.mockReturnValue({ event: null, setEvent: nextSetEvent });
rerender(
<React.StrictMode>
<HeadlampEventHandler />
</React.StrictMode>
);
callback({ type: 'headlamp.project-list-view', data: { projects: [project] } });

expect(registerHeadlampEventCallback).toHaveBeenCalledTimes(1);
expect(setEvent).not.toHaveBeenCalled();
expect(nextSetEvent).toHaveBeenCalledTimes(1);
expect(nextSetEvent).toHaveBeenCalledWith({
type: 'headlamp.project-list-view',
title: 'Projects',
projects: [project],
});

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

expect(setEvent).not.toHaveBeenCalled();
expect(nextSetEvent).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
Loading
Loading