Skip to content
Open
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
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