Compare commits

..

3 Commits

Author SHA1 Message Date
privilegedescalation-engineer 43b812da7b test: add unit test for withTimeout in IntelGpuDataContext
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 06:31:00 +00:00
Gandalf the Greybeard 5670c008e1 fix: add request timeout wrapper to prevent E2E test hang
Add withTimeout() helper that wraps ApiProxy.request calls with a 2s timeout.
This prevents the plugin from hanging indefinitely when CRD requests fail
or network issues occur in the E2E environment.

Root cause: ApiProxy.request to non-existent CRDs would hang forever,
causing the Loading Intel GPU data... progressbar to never resolve.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-25 05:57:15 +00:00
privilegedescalation-engineer f9325772bd fix(e2e): use specific regex for nodes page heading
The /node/i regex was too broad and matched both the page heading
'Intel GPU — Nodes' and the empty state 'No GPU Nodes Found',
causing a strict mode violation in Playwright.

Use /intel gpu.*nodes/i to match only the actual page heading,
which contains 'Intel GPU' before 'Nodes'.
2026-03-25 01:55:02 +00:00
14 changed files with 49 additions and 123 deletions
-2
View File
@@ -16,5 +16,3 @@ 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 }}
+2 -2
View File
@@ -11,7 +11,7 @@ permissions:
contents: read contents: read
# Only one E2E run at a time: the shared E2E_RELEASE (headlamp-e2e) in # Only one E2E run at a time: the shared E2E_RELEASE (headlamp-e2e) in
# headlamp-dev cannot be shared across concurrent runs. # privilegedescalation-dev cannot be shared across concurrent runs.
# cancel-in-progress: false (queue, don't cancel) — cancelling in-flight # cancel-in-progress: false (queue, don't cancel) — cancelling in-flight
# runs may skip the if: always() teardown, leaving dangling cluster resources. # runs may skip the if: always() teardown, leaving dangling cluster resources.
concurrency: concurrency:
@@ -19,7 +19,7 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
env: env:
E2E_NAMESPACE: headlamp-dev E2E_NAMESPACE: privilegedescalation-dev
E2E_RELEASE: headlamp-e2e E2E_RELEASE: headlamp-e2e
# Pin to a known-good Headlamp version. Using :latest is risky because # Pin to a known-good Headlamp version. Using :latest is risky because
# the tag can change between CI runs, causing flaky failures when a newer # the tag can change between CI runs, causing flaky failures when a newer
-53
View File
@@ -1,53 +0,0 @@
{
"config": {
// Line length — not enforced for docs with code examples
"MD013": false,
// First line heading — files use YAML frontmatter, not headings
"MD041": false,
// Emphasis as heading — common pattern for Option 1/2/3 sections
"MD036": false,
// No duplicate heading — changelog files repeat section names intentionally
"MD024": false,
// Fenced code language — not always applicable for diagram blocks
"MD040": false,
// Table column style — table alignment is visual, not semantic
"MD060": false,
// Ordered list item prefix — number resets are intentional in documents
"MD029": false,
// No inline HTML — each elements are valid in valid Markdown
"MD033": false,
// List marker space — spacing after list markers varies by editor
"MD030": false,
// Blanks around headings — not always needed in compact docs
"MD022": false,
// Blanks around lists — not always needed in compact docs
"MD032": false,
// Blanks around fences — not always needed between adjacent blocks
"MD031": false,
// Multiple blanks — editor artifacts, not semantic
"MD012": false,
// Single title — files may have multiple H1 sections
"MD025": false,
// Trailing spaces — editor artifacts
"MD009": false,
// Bare URLs — URL shortening not always needed
"MD034": false,
// Single trailing newline — editor artifacts
"MD047": false,
// Trailing punctuation — heading punctuation is intentional
"MD026": false,
// Space in emphasis — double-asterisk bold spacing varies by renderer
"MD037": false,
// No hard tabs — some generated docs use tabs for indentation
"MD010": false,
// Code block style — generated docs may use inconsistent styles
"MD046": false,
// Comment style — generated docs have no comments
"MD048": false,
// Commands show output — shell examples intentionally show only commands
"MD014": false
},
"ignores": [
"docs/api-reference/generated/**"
]
}
-1
View File
@@ -1 +0,0 @@
docs/api-reference/generated/**
+3 -3
View File
@@ -1,4 +1,4 @@
version: "1.1.0" version: "1.0.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.1.0/intel-gpu-1.1.0.tar.gz" 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-checksum: sha256:e212381f38c331383604b06f6552997fcba5c8b42a3bd828e3b43ed3e5028448 headlamp/plugin/archive-checksum: sha256:93d6c531e7c12440c9625138f0645fc0c3521b574d0089492759699b324943f0
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 -21
View File
@@ -19,18 +19,16 @@ 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( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible();
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' })
).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( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
// Either a populated table/list or an empty-state indicator must be visible // Either a populated table/list or an empty-state indicator must be visible
const hasTable = await page.locator('table').first().isVisible().catch(() => false); const hasTable = await page.locator('table').first().isVisible().catch(() => false);
@@ -45,9 +43,9 @@ 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( await expect(page.getByRole('heading', { name: /device plugin/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Device Plugins' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
const hasTable = await page.locator('table').first().isVisible().catch(() => false); const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page const hasEmptyState = await page
@@ -63,24 +61,18 @@ test.describe('Intel GPU plugin smoke tests', () => {
// not after clicking the parent entry from the overview. Test route // not after clicking the parent entry from the overview. Test route
// accessibility via direct navigation — each route must render its heading. // 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( await expect(page.getByRole('heading', { name: /intel.gpu/i })).toBeVisible({
page.locator('main').getByRole('heading', { name: 'Intel GPU — Overview' }) timeout: 15_000,
).toBeVisible({ timeout: 15_000 }); });
await page.goto('/c/main/intel-gpu/nodes'); await page.goto('/c/main/intel-gpu/nodes');
await expect( await expect(page.getByRole('heading', { name: /intel gpu.*nodes/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Nodes' })
).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/pods'); await page.goto('/c/main/intel-gpu/pods');
await expect( await expect(page.getByRole('heading', { name: /pod/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Pods' })
).toBeVisible({ timeout: 15_000 });
await page.goto('/c/main/intel-gpu/metrics'); await page.goto('/c/main/intel-gpu/metrics');
await expect( await expect(page.getByRole('heading', { name: /metric/i })).toBeVisible({ timeout: 15_000 });
page.locator('main').getByRole('heading', { name: 'Intel GPU — Metrics' })
).toBeVisible({ timeout: 15_000 });
}); });
test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => { test('plugin settings page shows intel-gpu plugin entry', async ({ page }) => {
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0", "@kinvolk/headlamp-plugin": "^0.13.0",
@@ -11600,9 +11600,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "intel-gpu", "name": "intel-gpu",
"version": "1.1.0", "version": "1.0.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",
@@ -44,7 +44,6 @@
}, },
"overrides": { "overrides": {
"tar": "^7.5.11", "tar": "^7.5.11",
"undici": "^7.24.3", "undici": "^7.24.3"
"lodash": ">=4.18.0"
} }
} }
+4 -9
View File
@@ -5,7 +5,7 @@
# a ConfigMap volume mount. No custom Docker images — the plugin is built # a ConfigMap volume mount. No custom Docker images — the plugin is built
# in CI and injected as a ConfigMap. # in CI and injected as a ConfigMap.
# #
# E2E resources are deployed to the `headlamp-dev` namespace. Nothing # E2E resources are deployed to the `privilegedescalation-dev` namespace. Nothing
# persists beyond the test run — teardown cleans up all created resources. # persists beyond the test run — teardown cleans up all created resources.
# #
# Prerequisites: # Prerequisites:
@@ -14,7 +14,7 @@
# - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml # - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
# #
# Environment: # Environment:
# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-dev) # E2E_NAMESPACE — namespace for E2E Headlamp (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest) # HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail set -euo pipefail
@@ -22,7 +22,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist" DIST_DIR="$REPO_ROOT/dist"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}" HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
@@ -59,15 +59,10 @@ kubectl create configmap headlamp-intel-gpu-plugin \
--from-file=package.json="$REPO_ROOT/package.json" --from-file=package.json="$REPO_ROOT/package.json"
# --- Tear down any existing E2E deployment for a clean start --- # --- Tear down any existing E2E deployment for a clean start ---
# Deleting the Deployment forces a fresh pod (new ReplicaSet) regardless of
# whether the pod spec changed. The ServiceAccount is also deleted for a clean
# token state. The Service is NOT deleted — leaving it in place avoids an
# Endpoints UID race (FailedToUpdateEndpoint) that causes DNS resolution
# failures. kubectl apply below upserts the Service in-place, and the new
# pod's IP is added to the existing Endpoints automatically.
echo "" echo ""
echo "Removing any existing E2E deployment (clean-start)..." echo "Removing any existing E2E deployment (clean-start)..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
# --- Deploy Headlamp via kubectl apply --- # --- Deploy Headlamp via kubectl apply ---
+2 -2
View File
@@ -4,13 +4,13 @@
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh. # Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
# #
# Environment: # Environment:
# E2E_NAMESPACE — namespace to clean up (default: headlamp-dev) # E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e) # E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}" E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}" E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
echo "=== E2E Headlamp Teardown ===" echo "=== E2E Headlamp Teardown ==="
+7 -13
View File
@@ -154,24 +154,18 @@ describe('IntelGpuDataProvider', () => {
it('treats a hanging CRD request as unavailable after 2s timeout', async () => { it('treats a hanging CRD request as unavailable after 2s timeout', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const nodeWrapper = { jsonData: {} }; vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[], null] as any);
vi.mocked(K8s.ResourceClasses.Node.useList).mockReturnValue([[nodeWrapper], null] as any); vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[], null] as any);
vi.mocked(K8s.ResourceClasses.Pod.useList).mockReturnValue([[nodeWrapper], null] as any); vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {}));
vi.mocked(ApiProxy.request)
.mockReturnValueOnce(new Promise(() => {}))
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({ items: [] });
const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper }); const { result } = renderHook(() => useIntelGpuContext(), { wrapper: Wrapper });
expect(result.current.loading).toBe(true); await act(async () => {
await vi.runAllTimersAsync();
});
vi.advanceTimersByTime(2000);
await act(async () => {});
expect(result.current.crdAvailable).toBe(false);
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false);
expect(result.current.crdAvailable).toBe(false);
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
+4 -1
View File
@@ -154,7 +154,10 @@ export function IntelGpuDataProvider({ children }: { children: React.ReactNode }
for (const url of pluginPodSelectors) { for (const url of pluginPodSelectors) {
try { try {
const list = await withTimeout(ApiProxy.request(url), DEFAULT_REQUEST_TIMEOUT_MS); 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);
+1 -3
View File
@@ -106,13 +106,11 @@ describe('MetricsPage', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('shows loader when ctxLoading=true but heading is visible immediately', () => { it('shows loader when ctxLoading=true', () => {
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...');
}); });
+6 -5
View File
@@ -230,6 +230,10 @@ export default function MetricsPage() {
}; };
}, [ctxLoading, fetchSeq]); }, [ctxLoading, fetchSeq]);
if (ctxLoading) {
return <Loader title="Loading Intel GPU data..." />;
}
return ( return (
<> <>
<div <div
@@ -243,7 +247,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 || ctxLoading} disabled={fetching}
aria-label="Refresh metrics" aria-label="Refresh metrics"
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
@@ -251,18 +255,15 @@ 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: fetching || ctxLoading ? 'not-allowed' : 'pointer', cursor: '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..." />}