Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ cypress-a11y-report.json
/dynamic-demo-plugin/**/dist
**/.claude/settings.local.json
**/chartstore-*/
.playwright-mcp/**
157 changes: 157 additions & 0 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,163 @@ export default class KubernetesClient {
}
}

async createClusterCustomResource(
group: string,
version: string,
plural: string,
body: Record<string, unknown>,
): Promise<unknown> {
const response = await this.coApi.createClusterCustomObject({
body,
group,
plural,
version,
});
return response;
}

async deleteClusterCustomResource(
group: string,
version: string,
plural: string,
name: string,
): Promise<void> {
try {
await this.coApi.deleteClusterCustomObject({ group, name, plural, version });
} catch (err) {
if (!isNotFound(err)) {
throw err;
}
}
}

async getClusterCustomResource(
group: string,
version: string,
plural: string,
name: string,
): Promise<unknown> {
const response = await this.coApi.getClusterCustomObject({ group, name, plural, version });
return response;
}

async listClusterCustomResources(
group: string,
version: string,
plural: string,
): Promise<unknown[]> {
try {
const response = await this.coApi.listClusterCustomObject({ group, plural, version });
return (response as any)?.items || [];
} catch {
return [];
}
}

private async mergePatch(apiPath: string, patch: Record<string, unknown>): Promise<unknown> {
const cluster = this.kubeConfig.getCurrentCluster();
if (!cluster?.server) {
throw new Error('No cluster configured in kubeconfig');
}
const url = new URL(apiPath, cluster.server);
const opts: https.RequestOptions = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json', Accept: 'application/json' },
rejectUnauthorized: false,
};
await this.kubeConfig.applyToHTTPSOptions(opts);
return new Promise((resolve, reject) => {
const req = https.request(opts, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
Comment on lines +545 to +547
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle empty successful PATCH responses before JSON parsing.

Line 546 unconditionally parses body; a valid 2xx response with an empty body will throw and incorrectly fail the patch flow.

Proposed fix
         res.on('end', () => {
           if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
-            resolve(JSON.parse(body));
+            if (!body.trim()) {
+              resolve({});
+              return;
+            }
+            resolve(JSON.parse(body));
           } else {
             const msg = `Merge patch failed: HTTP ${res.statusCode} ${body.substring(0, 500)}`;
             reject(new Error(msg));
           }
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
if (!body.trim()) {
resolve({});
return;
}
resolve(JSON.parse(body));
} else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/clients/kubernetes-client.ts` around lines 545 - 547, The
success branch unconditionally calls resolve(JSON.parse(body)) which will throw
on an empty 2xx PATCH response; update the conditional in the handler around
resolve(JSON.parse(body)) to check for an empty or whitespace-only body (e.g.,
body == null || body.trim() === "") and if so resolve(undefined or null) instead
of parsing, otherwise parse and resolve the JSON; change the code that currently
calls resolve(JSON.parse(body)) so it safely handles empty successful responses.

const msg = `Merge patch failed: HTTP ${res.statusCode} ${body.substring(0, 500)}`;
reject(new Error(msg));
}
});
});
req.on('error', reject);
req.write(JSON.stringify(patch));
req.end();
});
}

async patchCustomResource(
group: string,
version: string,
namespace: string,
plural: string,
name: string,
patch: Record<string, unknown>,
): Promise<unknown> {
const apiPath = `/apis/${group}/${version}/namespaces/${namespace}/${plural}/${name}`;
return this.mergePatch(apiPath, patch);
}

async patchClusterCustomResource(
group: string,
version: string,
plural: string,
name: string,
patch: Record<string, unknown>,
): Promise<unknown> {
const apiPath = `/apis/${group}/${version}/${plural}/${name}`;
return this.mergePatch(apiPath, patch);
}

async waitForCustomResourceCondition(
group: string,
version: string,
namespace: string,
plural: string,
name: string,
conditionFn: (resource: any) => boolean,
timeoutMs: number,
): Promise<boolean> {
return pollUntil(
async () => {
try {
const resource = await this.getCustomResource(group, version, namespace, plural, name);
return conditionFn(resource);
} catch {
return false;
}
},
timeoutMs,
2_000,
);
}

async waitForClusterCustomResourceCondition(
group: string,
version: string,
plural: string,
name: string,
conditionFn: (resource: any) => boolean,
timeoutMs: number,
): Promise<boolean> {
return pollUntil(
async () => {
try {
const resource = await this.getClusterCustomResource(group, version, plural, name);
return conditionFn(resource);
} catch {
return false;
}
},
timeoutMs,
2_000,
);
}

async getPods(namespace: string): Promise<k8s.V1Pod[]> {
const response = await this.k8sApi.listNamespacedPod({ namespace });
return response.items || [];
Expand Down
30 changes: 30 additions & 0 deletions frontend/e2e/fixtures/cleanup-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface CleanupFixture {
plural: string,
type?: string,
): void;
trackClusterCustomResource(
name: string,
apiGroup: string,
apiVersion: string,
plural: string,
type?: string,
): void;
readonly count: number;
executeCleanup(): Promise<void>;
shouldSkipCleanup(): boolean;
Expand Down Expand Up @@ -95,6 +102,22 @@ export function createCleanupFixture(testName: string): CleanupFixture {
});
},

trackClusterCustomResource(
name: string,
apiGroup: string,
apiVersion: string,
plural: string,
type?: string,
) {
resources.push({
name,
apiGroup,
apiVersion,
plural,
type: type || plural,
});
},

get count() {
return resources.length;
},
Expand Down Expand Up @@ -142,6 +165,13 @@ export function createCleanupFixture(testName: string): CleanupFixture {
resource.plural,
resource.name,
);
} else if (resource.apiGroup) {
await client.deleteClusterCustomResource(
resource.apiGroup,
resource.apiVersion,
resource.plural,
resource.name,
);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
Expand Down
68 changes: 68 additions & 0 deletions frontend/e2e/pages/details-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class DetailsPage extends BasePage {
private readonly pageHeading = this.page.locator('[data-test="page-heading"]');
private readonly resourceTitle = this.page.locator('[data-test-id="resource-title"]');
private readonly skeletonDetailView = this.page.getByTestId('skeleton-detail-view');
private readonly actionsMenuButton = this.page.locator('[data-test-id="actions-menu-button"]');

constructor(page: Page) {
super(page);
}

async isLoaded(): Promise<void> {
await expect(this.skeletonDetailView).not.toBeAttached({ timeout: 30_000 });
await expect(this.resourceTitle).not.toBeEmpty({ timeout: 30_000 });
}

async titleShouldContain(title: string): Promise<void> {
await expect(this.pageHeading).toBeAttached({ timeout: 30_000 });
await expect(this.pageHeading).toContainText(title, { timeout: 30_000 });
}

async sectionHeaderShouldExist(heading: string): Promise<void> {
await expect(this.page.locator(`[data-test-section-heading="${heading}"]`)).toBeAttached();
}

async selectTab(name: string): Promise<void> {
const tab = this.page.locator(`[data-test-id="horizontal-link-${name}"]`);
await expect(tab).toBeAttached();
await this.robustClick(tab);
await this.waitForLoadingComplete();
}

async clickPageActionFromDropdown(actionID: string): Promise<void> {
await this.robustClick(this.actionsMenuButton);
await this.robustClick(this.page.locator(`[data-test-action="${actionID}"]:not([disabled])`));
}

async clickPageActionButton(action: string): Promise<void> {
const actionButton = this.page.locator('[data-test-id="details-actions"]', {
hasText: action,
});
await this.robustClick(actionButton);
}

sectionHeading(name: string): Locator {
return this.page.locator(`[data-test-section-heading="${name}"]`);
}

detailsItemLabel(name: string): Locator {
return this.page.locator(`[data-test-selector="details-item-label__${name}"]`);
}

detailsItemValue(name: string): Locator {
return this.page.locator(`[data-test-selector="details-item-value__${name}"]`);
}

horizontalNavTab(tabId: string): Locator {
return this.page.locator(`[data-test-id="horizontal-link-${tabId}"]`);
}

breadcrumb(index: number): Locator {
return this.page.locator(`[data-test-id="breadcrumb-link-${index}"]`);
}
}
33 changes: 33 additions & 0 deletions frontend/e2e/pages/list-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class ListPage extends BasePage {
private readonly pageHeading = this.page.locator('[data-test="page-heading"]');
private readonly nameFilterInput = this.page.getByTestId('name-filter-input');

constructor(page: Page) {
super(page);
}

async titleShouldHaveText(title: string): Promise<void> {
await expect(this.pageHeading).toHaveText(title);
}

async filterByName(name: string): Promise<void> {
await this.nameFilterInput.fill(name);
}

resourceRow(name: string): Locator {
return this.page.locator(`[data-test-rows="resource-row"]`, { hasText: name });
}

async rowShouldExist(name: string): Promise<void> {
await expect(this.resourceRow(name)).toBeAttached();
}

async rowShouldNotExist(name: string): Promise<void> {
await expect(this.resourceRow(name)).not.toBeAttached();
}
}
43 changes: 43 additions & 0 deletions frontend/e2e/pages/modal-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class ModalPage extends BasePage {
private readonly cancelButton = this.page.locator('[data-test-id="modal-cancel-action"]');
private readonly submitBtn = this.page.locator('button[type=submit]');
private readonly modalTitle = this.page.locator('[data-test-id="modal-title"]');

constructor(page: Page) {
super(page);
}

async shouldBeOpened(): Promise<void> {
await this.cancelButton.scrollIntoViewIfNeeded({ timeout: 20_000 });
await expect(this.cancelButton).toBeVisible();
}

async shouldBeClosed(): Promise<void> {
await expect(this.cancelButton).not.toBeAttached();
}

async submit(force = false): Promise<void> {
await this.robustClick(this.submitBtn, { force });
}

async cancel(force = false): Promise<void> {
await this.robustClick(this.cancelButton, { force });
}

async modalTitleShouldContain(title: string): Promise<void> {
await expect(this.modalTitle).toContainText(title);
}

async submitShouldBeDisabled(): Promise<void> {
await expect(this.submitBtn).toBeDisabled();
}

async submitShouldBeEnabled(): Promise<void> {
await expect(this.submitBtn).not.toBeDisabled();
}
}
30 changes: 30 additions & 0 deletions frontend/e2e/pages/nav-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Page } from '@playwright/test';

import BasePage from './base-page';

export class NavPage extends BasePage {
private readonly sidebar = this.page.locator('#page-sidebar');

constructor(page: Page) {
super(page);
}

async clickNavLink(path: string[]): Promise<void> {
if (path.length === 2) {
const parentButton = this.sidebar.getByRole('button', { name: path[0], exact: true });
const isExpanded =
(await parentButton.getAttribute('aria-expanded').catch(() => null)) === 'true';
if (!isExpanded) {
await this.robustClick(parentButton);
}
const childLink = this.sidebar
.getByRole('region', { name: path[0] })
.getByRole('link', { name: path[1], exact: true });
await this.robustClick(childLink);
} else {
const targetButton = this.sidebar.getByRole('button', { name: path[0], exact: true });
await this.robustClick(targetButton);
}
await this.waitForLoadingComplete();
}
}
Loading