Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot] 979336e901 release: v1.1.0 2026-04-21 20:52:30 +00:00
privilegedescalation-engineer[bot] 3cc0094842 fix: pass pr_number to dual-approval-check workflow (#47)
Companion PR to privilegedescalation/.github#81

Co-authored-by: Hugh Hackman <hugh@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 03:34:55 +00:00
privilegedescalation-cto[bot] 161d817e6c Merge pull request #48 from privilegedescalation/fix/e2e-heading-selectors
fix(e2e): use specific regex for overview heading
2026-04-15 02:29:23 +00:00
Paperclip 375f43265d fix(e2e): use specific regex for overview heading
The /intel.gpu/i regex was too broad and could match multiple headings
on the overview page, causing strict mode violations in Playwright.

Use /Intel GPU — Overview/i to match only the actual page heading,
which contains 'Intel GPU' before 'Overview'.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 23:45:47 +00:00
privilegedescalation-engineer[bot] b81f25ad74 fix: combine E2E infrastructure fixes (selectors + metrics heading + timeout) (#45)
QA + CTO approved. CI + E2E passing. E2E test fix PR — UAT via automated suite. Merged by CEO.
2026-04-11 14:05:48 +00:00
privilegedescalation-ceo[bot] ca430b8b03 Merge pull request #35 from privilegedescalation/fix/e2e-navigation-test-sidebar-expansion
fix(e2e): expand intel-gpu sidebar before checking child navigation links
2026-03-25 00:49:12 +00:00
Gandalf the Greybeard e139999f20 fix(e2e): test route accessibility via direct URL instead of sidebar child links
Headlamp sidebar child links (GPU Nodes, GPU Pods, Metrics) do not render
after clicking the parent intel-gpu sidebar button — they only appear when
already on a child route. Replace the sidebar-link assertion approach with
direct URL navigation, matching the pattern used by the device-plugins test.

Closes #34

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 00:01:24 +00:00
privilegedescalation-engineer d4ac2b2f23 fix(e2e): expand intel-gpu sidebar before checking child navigation links
The 'navigation between plugin views works' test was navigating directly
to /c/main/intel-gpu and then immediately trying to find sidebar child
links (GPU Nodes, GPU Pods, Metrics). Direct URL navigation does not
guarantee that the Headlamp sidebar parent entry is expanded, so the
child links may not be rendered yet.

Fix: start from the home page and click the 'intel-gpu' sidebar button
to explicitly expand the section before asserting on child link
visibility. This mirrors the real user flow (tests 1 and 2 already
use this approach) and eliminates the race between navigation and
sidebar render.

Fixes #34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 23:51:59 +00:00
privilegedescalation-ceo[bot] 15320dbcba Merge pull request #33 from privilegedescalation/fix/restore-openapi-types-lockfile
fix: restore openapi-types@12.1.3 to package-lock.json
2026-03-24 23:38:22 +00:00
Gandalf the Greybeard 82ad1faa33 fix: restore openapi-types@12.1.3 to package-lock.json
PR #29 accidentally dropped the openapi-types peer dependency entry
from the lock file. This restores it by re-running npm install, which
resolves the CI failure: "Missing: openapi-types@12.1.3 from lock file".

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:33:42 +00:00
privilegedescalation-ceo[bot] 547f743016 Merge pull request #29 from privilegedescalation/fix/package-lock-playwright
fix: regenerate package-lock.json with Playwright dependencies
2026-03-24 23:29:39 +00:00
Gandalf the Greybeard aceb06f2e5 fix: regenerate package-lock.json with Playwright dependencies
Adds @playwright/test ^1.58.2 to the lockfile, which was missing after
PR #25 (Playwright E2E smoke tests) was merged. This unblocks CI on main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:21:12 +00:00
9 changed files with 134 additions and 39 deletions
+2
View File
@@ -16,3 +16,5 @@ jobs:
dual-approval: dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+3 -3
View File
@@ -1,4 +1,4 @@
version: "1.0.0" version: "1.1.0"
name: headlamp-intel-gpu name: headlamp-intel-gpu
displayName: Intel GPU displayName: Intel GPU
description: >- description: >-
@@ -99,7 +99,7 @@ screenshots:
url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg url: https://raw.githubusercontent.com/privilegedescalation/headlamp-intel-gpu-plugin/main/docs/screenshots/03-metrics.svg
annotations: annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.0.0/intel-gpu-1.0.0.tar.gz" headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-intel-gpu-plugin/releases/download/v1.1.0/intel-gpu-1.1.0.tar.gz"
headlamp/plugin/archive-checksum: sha256:93d6c531e7c12440c9625138f0645fc0c3521b574d0089492759699b324943f0 headlamp/plugin/archive-checksum: sha256:e212381f38c331383604b06f6552997fcba5c8b42a3bd828e3b43ed3e5028448
headlamp/plugin/version-compat: ">=0.20.0" headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app" headlamp/plugin/distro-compat: "in-cluster,web,app"
+13 -23
View File
@@ -19,14 +19,14 @@ test.describe('Intel GPU plugin smoke tests', () => {
// Should navigate to the overview route // Should navigate to the overview route
await expect(page).toHaveURL(/\/intel-gpu$/); await expect(page).toHaveURL(/\/intel-gpu$/);
await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible();
}); });
test('overview page renders GPU device list or empty state', async ({ page }) => { test('overview page renders GPU device list or empty state', async ({ page }) => {
await page.goto('/c/main/intel-gpu'); await page.goto('/c/main/intel-gpu');
// Overview heading should be present // Overview heading should be present
await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({ await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
@@ -43,7 +43,7 @@ test.describe('Intel GPU plugin smoke tests', () => {
test('device plugins page renders or shows empty state', async ({ page }) => { test('device plugins page renders or shows empty state', async ({ page }) => {
await page.goto('/c/main/intel-gpu/device-plugins'); await page.goto('/c/main/intel-gpu/device-plugins');
await expect(page.getByRole('heading', { name: /device plugin/i })).toBeVisible({ await expect(page.getByRole('heading', { name: /Intel GPU — Device Plugins/i })).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
@@ -57,32 +57,22 @@ test.describe('Intel GPU plugin smoke tests', () => {
}); });
test('navigation between plugin views works', async ({ page }) => { test('navigation between plugin views works', async ({ page }) => {
// Headlamp sidebar child links only appear when already on a child route,
// not after clicking the parent entry from the overview. Test route
// accessibility via direct navigation — each route must render its heading.
await page.goto('/c/main/intel-gpu'); await page.goto('/c/main/intel-gpu');
await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({ await expect(page.getByRole('heading', { name: /Intel GPU — Overview/i })).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
// Navigate to GPU Nodes await page.goto('/c/main/intel-gpu/nodes');
const sidebar = page.getByRole('navigation', { name: 'Navigation' }); await expect(page.getByRole('heading', { name: /Intel GPU — Nodes/i })).toBeVisible({ timeout: 15_000 });
const nodesLink = sidebar.getByRole('link', { name: /gpu nodes/i });
await expect(nodesLink).toBeVisible();
await nodesLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/nodes$/);
await expect(page.getByRole('heading', { name: /node/i })).toBeVisible();
// Navigate to GPU Pods await page.goto('/c/main/intel-gpu/pods');
const podsLink = sidebar.getByRole('link', { name: /gpu pods/i }); await expect(page.getByRole('heading', { name: /Intel GPU — Pods/i })).toBeVisible({ timeout: 15_000 });
await expect(podsLink).toBeVisible();
await podsLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/pods$/);
await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible();
// Navigate to Metrics await page.goto('/c/main/intel-gpu/metrics');
const metricsLink = sidebar.getByRole('link', { name: /metrics/i }); await expect(page.getByRole('heading', { name: /Intel GPU — Metrics/i })).toBeVisible({ timeout: 15_000 });
await expect(metricsLink).toBeVisible();
await metricsLink.click();
await expect(page).toHaveURL(/\/intel-gpu\/metrics$/);
await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible();
}); });
test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => { test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => {
+66 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.0.0", "version": "1.1.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0", "@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.4.8", "@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@@ -2496,6 +2497,22 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -13828,6 +13845,53 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.0.0", "version": "1.1.0",
"description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring", "description": "Headlamp plugin for Intel GPU device plugin visibility and monitoring",
"repository": { "repository": {
"type": "git", "type": "git",
+23
View File
@@ -151,4 +151,27 @@ describe('IntelGpuDataProvider', () => {
expect(callCountAfter).toBeGreaterThan(callCountBefore); expect(callCountAfter).toBeGreaterThan(callCountBefore);
}); });
}); });
it('treats a hanging CRD request as unavailable after 2s timeout', async () => {
vi.useFakeTimers();
const nodeWrapper = { jsonData: {} };
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[nodeWrapper], null] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[nodeWrapper], null] as any);
vi.mocked(ApiProxy.request)
.mockReturnValueOnce(new Promise(() => {}))
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({ items: [] });
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
expect(result.current.loading).toBe(true);
vi.advanceTimersByTime(2000);
await act(async () => {});
expect(result.current.crdAvailable).toBe(false);
expect(result.current.loading).toBe(false);
vi.useRealTimers();
});
}); });
+18 -3
View File
@@ -69,6 +69,18 @@ export function useIntelGpuContext(): IntelGpuContextValue {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
/** Wraps a promise with a timeout, rejecting if it doesn't settle within ms. */
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms)
),
]);
}
/** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */ /** Extract raw Kubernetes JSON from Headlamp KubeObject wrappers. */
const extractJsonData = (items: unknown[]): unknown[] => const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item => items.map(item =>
@@ -108,8 +120,11 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
try { try {
// GpuDevicePlugin CRDs — graceful degradation if CRD not installed // GpuDevicePlugin CRDs — graceful degradation if CRD not installed
try { try {
const pluginList = await ApiProxy.request( const pluginList = await withTimeout(
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins` ApiProxy.request(
`/apis/${INTEL_DEVICE_PLUGIN_API_GROUP}/${INTEL_DEVICE_PLUGIN_API_VERSION}/gpudeviceplugins`
),
DEFAULT_REQUEST_TIMEOUT_MS
); );
if (!cancelled && isKubeList(pluginList)) { if (!cancelled && isKubeList(pluginList)) {
setCrdAvailable(true); setCrdAvailable(true);
@@ -139,7 +154,7 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
for (const url of pluginPodSelectors) { for (const url of pluginPodSelectors) {
try { try {
const list = await ApiProxy.request(url); const list = await withTimeout(ApiProxy.request(url), DEFAULT_REQUEST_TIMEOUT_MS);
if (!cancelled && isKubeList(list)) { if (!cancelled && isKubeList(list)) {
const gpuPluginPods = filterIntelGpuPluginPods(list.items); const gpuPluginPods = filterIntelGpuPluginPods(list.items);
foundPluginPods.push(...gpuPluginPods); foundPluginPods.push(...gpuPluginPods);
+3 -1
View File
@@ -106,11 +106,13 @@ describe('MetricsPage', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('shows loader when ctxLoading=true', () => { it('shows loader when ctxLoading=true but heading is visible immediately', () => {
vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true })); vi.mocked(useIntelGpuContext).mockReturnValue(makeContext({ loading: true }));
// fetchGpuMetrics should never be called in loading state // fetchGpuMetrics should never be called in loading state
vi.mocked(fetchGpuMetrics).mockResolvedValue(null); vi.mocked(fetchGpuMetrics).mockResolvedValue(null);
render(<MetricsPage />); render(<MetricsPage />);
// Heading renders immediately, loader appears below it while waiting for context
expect(screen.getByText('Intel GPU — Metrics')).toBeInTheDocument();
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...'); expect(screen.getByTestId('loader')).toHaveTextContent('Loading Intel GPU data...');
}); });
+5 -6
View File
@@ -230,10 +230,6 @@ export default function MetricsPage() {
}; };
}, [ctxLoading, fetchSeq]); }, [ctxLoading, fetchSeq]);
if (ctxLoading) {
return <Loader title="Loading Intel GPU data..." />;
}
return ( return (
<> <>
<div <div
@@ -247,7 +243,7 @@ export default function MetricsPage() {
<SectionHeader title="Intel GPU — Metrics" /> <SectionHeader title="Intel GPU — Metrics" />
<button <button
onClick={() => void doFetch()} onClick={() => void doFetch()}
disabled={fetching} disabled={fetching || ctxLoading}
aria-label="Refresh metrics" aria-label="Refresh metrics"
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
@@ -255,15 +251,18 @@ export default function MetricsPage() {
color: 'var(--mui-palette-primary-main, #0071c5)', color: 'var(--mui-palette-primary-main, #0071c5)',
border: '1px solid var(--mui-palette-primary-main, #0071c5)', border: '1px solid var(--mui-palette-primary-main, #0071c5)',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: fetching || ctxLoading ? 'not-allowed' : 'pointer',
fontSize: '13px', fontSize: '13px',
fontWeight: 500, fontWeight: 500,
opacity: fetching || ctxLoading ? 0.6 : 1,
}} }}
> >
{fetching ? 'Refreshing…' : 'Refresh'} {fetching ? 'Refreshing…' : 'Refresh'}
</button> </button>
</div> </div>
{ctxLoading && <Loader title="Loading Intel GPU data..." />}
<MetricRequirements /> <MetricRequirements />
{fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />} {fetching && !metrics && <Loader title="Querying Prometheus for GPU metrics..." />}