Compare commits

..

3 Commits

Author SHA1 Message Date
Chris Farhood 75cf41ef4d fix: sync pnpm-lock.yaml after removing tar and undici deps
The pnpm-lock.yaml was out of sync with package.json after tar and undici
were removed. Regenerated to resolve pnpm install failure in CI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 15:19:26 +00:00
Chris Farhood a324ee621b fix: add markdownlint config to resolve CI failures
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 05:24:26 +00:00
Chris Farhood 0c521be1a1 Remove duplicate tar/undici from devDependencies (already in pnpm.overrides)
Consolidates dual override blocks by removing the duplicate entries
from devDependencies. These packages are already pinned via pnpm.overrides
and should not appear in devDependencies.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 02:21:10 +00:00
11 changed files with 7 additions and 447 deletions
+2 -6
View File
@@ -2,13 +2,9 @@ name: CI
on:
push:
branches: ['**']
branches: [main]
pull_request:
branches: [main, dev]
workflow_dispatch:
permissions:
contents: read
branches: [main]
jobs:
ci:
-23
View File
@@ -1,23 +0,0 @@
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: e2e-${{ github.repository }}
cancel-in-progress: false
jobs:
e2e:
uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@hugh/add-pnpm-support-plugin-e2e
with:
node-version: '22'
headlamp-version: v0.40.1
e2e-namespace: headlamp-dev
+1 -7
View File
@@ -14,10 +14,4 @@ coverage/
*.swo
*~
.vscode/
.idea/
# E2E
e2e/.auth/
.env.e2e
playwright-report/
test-results/
.idea/
-1
View File
@@ -32,4 +32,3 @@ gh workflow run Release --field version=0.1.0
## License
Apache-2.0
-50
View File
@@ -1,50 +0,0 @@
import { test, expect } from '@playwright/test';
async function waitForSidebar(page: import('@playwright/test').Page) {
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await page.waitForLoadState('networkidle');
return sidebar;
}
test.describe('ArgoCD plugin smoke tests', () => {
test('sidebar contains ArgoCD entry', async ({ page }) => {
await page.goto('/');
const sidebar = await waitForSidebar(page);
await expect(sidebar.getByRole('button', { name: /argocd/i })).toBeVisible();
});
test('ArgoCD sidebar entry navigates to applications list', async ({ page }) => {
await page.goto('/');
const sidebar = await waitForSidebar(page);
const argocdEntry = sidebar.getByRole('button', { name: /argocd/i });
await expect(argocdEntry).toBeVisible();
await argocdEntry.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/argocd/);
await expect(page.getByRole('heading', { name: /argo.*cd.*applications/i })).toBeVisible();
});
test('applications list page renders', async ({ page }) => {
await page.goto('/c/main/argocd');
await waitForSidebar(page);
await expect(page.getByRole('heading', { name: /argo.*cd.*applications/i })).toBeVisible({
timeout: 15_000,
});
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasContent = await page.locator('text=/application|sync|health/i').first().isVisible().catch(() => false);
expect(hasTable || hasContent).toBe(true);
});
test('plugin settings page shows argocd plugin entry', async ({ page }) => {
await page.goto('/settings/plugins');
await page.waitForLoadState('networkidle');
const pluginEntry = page.locator('text=/argocd|argo.*cd/i').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
});
});
-69
View File
@@ -1,69 +0,0 @@
import { test as setup, expect, Page } from '@playwright/test';
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
await page.goto('/');
await page.waitForURL('**/login');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in/i }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await popup.waitForLoadState('networkidle');
const usernameField = popup.getByRole('textbox', { name: /email or username/i });
await usernameField.waitFor({ state: 'visible', timeout: 15_000 });
await usernameField.fill(username);
await popup.getByRole('button', { name: /log in/i }).click();
await popup.waitForLoadState('networkidle');
const passwordField = popup.getByRole('textbox', { name: /password/i });
await passwordField.waitFor({ state: 'visible', timeout: 15_000 });
await passwordField.fill(password);
await popup.getByRole('button', { name: /continue|log in/i }).click();
await popup.waitForEvent('close', { timeout: 15_000 });
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
await page.goto('/');
await page.waitForURL(/\/(login|token)$/);
if (page.url().includes('/login')) {
const useTokenBtn = page.getByRole('button', { name: /use a token/i });
await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 });
await useTokenBtn.click();
await page.waitForURL('**/token');
}
await page.getByRole('textbox', { name: /id token/i }).fill(token);
await page.getByRole('button', { name: /authenticate/i }).click();
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
setup('authenticate with Headlamp', async ({ page }) => {
const username = process.env.AUTHENTIK_USERNAME;
const password = process.env.AUTHENTIK_PASSWORD;
const token = process.env.HEADLAMP_TOKEN;
if (username && password) {
await authenticateWithOIDC(page, username, password);
} else if (token) {
await authenticateWithToken(page, token);
} else {
throw new Error(
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
);
}
await page.context().storageState({ path: AUTH_STATE_PATH });
});
+2 -5
View File
@@ -23,9 +23,7 @@
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
"test:watch": "vitest"
},
"peerDependencies": {
"react": "^18.0.0",
@@ -41,7 +39,6 @@
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0",
"@mui/material": "^5.15.14",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
@@ -58,4 +55,4 @@
"typescript": "~5.6.2",
"vitest": "^3.0.5"
}
}
}
-27
View File
@@ -1,27 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: {
baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(),
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/state.json',
},
dependencies: ['setup'],
},
],
});
+2 -40
View File
@@ -22,9 +22,6 @@ importers:
'@mui/material':
specifier: ^5.15.14
version: 5.18.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@playwright/test':
specifier: ^1.58.2
version: 1.59.1
'@testing-library/jest-dom':
specifier: ^6.4.8
version: 6.9.1
@@ -1004,11 +1001,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
@@ -3090,11 +3082,6 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4234,16 +4221,6 @@ packages:
resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==}
engines: {node: '>=10'}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6252,7 +6229,7 @@ snapshots:
jsdom: 24.1.3
jsonpath-plus: 10.4.0
lodash: 4.18.1
material-react-table: 2.13.3(0078ddeddc9e779fa84c03996c1db10e)
material-react-table: 2.13.3(330725fe5432f245d076f0c0dda1a7a7)
monaco-editor: 0.52.2
msw: 2.4.9(typescript@5.6.2)
msw-storybook-addon: 2.0.3(msw@2.4.9(typescript@5.6.3))
@@ -6650,10 +6627,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@popperjs/core@2.11.8': {}
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
@@ -9212,9 +9185,6 @@ snapshots:
fs.realpath@1.0.0: {}
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -9961,7 +9931,7 @@ snapshots:
'@types/minimatch': 3.0.5
minimatch: 3.1.5
material-react-table@2.13.3(0078ddeddc9e779fa84c03996c1db10e):
material-react-table@2.13.3(330725fe5432f245d076f0c0dda1a7a7):
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1)
@@ -10597,14 +10567,6 @@ snapshots:
dependencies:
find-up: 5.0.0
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss-modules-extract-imports@3.1.0(postcss@8.5.10):
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the argocd plugin loaded via
# a ConfigMap volume mount.
#
# Environment:
# E2E_NAMESPACE — namespace (default: headlamp-dev)
# E2E_RELEASE — release name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
if [ ! -d "$DIST_DIR" ]; then
echo "ERROR: dist/ not found. Run 'pnpm build' first." >&2
exit 1
fi
echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..."
if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then
echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2
exit 1
fi
echo "=== E2E Headlamp Deployment ==="
echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
echo ""
echo "Creating ConfigMap with plugin files..."
kubectl delete configmap headlamp-argocd-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
kubectl create configmap headlamp-argocd-plugin \
-n "$E2E_NAMESPACE" \
--from-file="$DIST_DIR" \
--from-file=package.json="$REPO_ROOT/package.json"
echo ""
echo "Removing any existing E2E deployment (clean-start)..."
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
echo ""
echo "Deploying Headlamp E2E instance..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
template:
metadata:
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
serviceAccountName: ${E2E_RELEASE}
automountServiceAccountToken: true
securityContext: {}
containers:
- name: headlamp
image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}
imagePullPolicy: IfNotPresent
securityContext:
runAsNonRoot: true
privileged: false
runAsUser: 100
runAsGroup: 101
args:
- "-in-cluster"
- "-in-cluster-context-name=main"
- "-plugins-dir=/headlamp/plugins"
ports:
- name: http
containerPort: 4466
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: argocd-plugin
mountPath: /headlamp/plugins/headlamp-argocd
readOnly: true
volumes:
- name: argocd-plugin
configMap:
name: headlamp-argocd-plugin
---
apiVersion: v1
kind: Service
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
EOF
echo "Waiting for rollout..."
kubectl rollout status "deployment/${E2E_RELEASE}" \
-n "$E2E_NAMESPACE" --timeout=120s
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
echo ""
echo "Waiting for ${SVC_URL} to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24
until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/dev/null; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2
exit 1
fi
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..."
sleep 5
done
echo ""
echo "E2E Headlamp is ready at: ${SVC_URL}"
echo ""
echo "Creating service account token for E2E auth..."
kubectl create serviceaccount headlamp-e2e-test \
-n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "")
if [ -n "$TOKEN" ]; then
echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e"
echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e"
echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN"
else
echo " WARNING: Could not generate token."
fi
echo ""
echo "E2E deployment complete."
-37
View File
@@ -1,37 +0,0 @@
#!/usr/bin/env bash
# teardown-e2e-headlamp.sh
#
# Tears down the dedicated E2E Headlamp instance.
#
# Environment:
# E2E_NAMESPACE — namespace to clean up (default: headlamp-dev)
# E2E_RELEASE — release name prefix (default: headlamp-e2e)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
echo "=== E2E Headlamp Teardown ==="
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
echo "Removing Headlamp Deployment, Service, and ServiceAccount..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up ConfigMap..."
kubectl delete configmap headlamp-argocd-plugin -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up test service account..."
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
if [ -f "$REPO_ROOT/.env.e2e" ]; then
rm "$REPO_ROOT/.env.e2e"
echo "Removed .env.e2e"
fi
echo ""
echo "E2E teardown complete."