Skip to content

Commit 7ae82f2

Browse files
committed
Add E2E tests for Workloads/Cronjob in the dashboard
Signed-off-by: SunsetB612 <[email protected]>
1 parent 8d87479 commit 7ae82f2

File tree

9 files changed

+726
-8
lines changed

9 files changed

+726
-8
lines changed

ui/apps/dashboard/e2e/test-utils.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ limitations under the License.
1616

1717
import { Page } from '@playwright/test';
1818
import * as k8s from '@kubernetes/client-node';
19+
import { Configuration } from '@kubernetes/client-node';
20+
21+
type ApiConstructor<T> = new (config: Configuration, ...args: any[]) => T;
1922

2023
// Set webServer.url and use.baseURL with the location of the WebServer
2124
const HOST = process.env.HOST || 'localhost';
@@ -31,9 +34,9 @@ const KARMADA_API_SERVER = `https://${KARMADA_HOST}:${KARMADA_PORT}`;
3134

3235
/**
3336
* Creates a configured Kubernetes API client for Karmada
34-
* @returns Kubernetes AppsV1Api client
37+
* @returns Kubernetes API client instance
3538
*/
36-
export function createKarmadaApiClient(): k8s.AppsV1Api {
39+
export function createKarmadaApiClient<ApiType extends k8s.ApiType>(apiClientType: ApiConstructor<ApiType>): ApiType{
3740
const kc = new k8s.KubeConfig();
3841

3942
// Try to use existing kubeconfig first (for CI)
@@ -46,7 +49,7 @@ export function createKarmadaApiClient(): k8s.AppsV1Api {
4649
if (karmadaContext) {
4750
kc.setCurrentContext('karmada-apiserver');
4851
}
49-
return kc.makeApiClient(k8s.AppsV1Api);
52+
return kc.makeApiClient(apiClientType);
5053
} catch (error) {
5154
console.error('Failed to load kubeconfig:', error);
5255
}
@@ -74,7 +77,7 @@ users:
7477
`;
7578

7679
kc.loadFromString(kubeConfigYaml);
77-
return kc.makeApiClient(k8s.AppsV1Api);
80+
return kc.makeApiClient(apiClientType);
7881
}
7982

8083
/**
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
Copyright 2025 The Karmada Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
import { test, expect } from '@playwright/test';
15+
import { setupDashboardAuthentication, generateTestCronJobYaml, getCronJobNameFromYaml, deleteK8sCronJob } from './test-utils';
16+
17+
test.beforeEach(async ({ page }) => {
18+
await setupDashboardAuthentication(page);
19+
});
20+
21+
test('should create a new cronjob', async ({ page }) => {
22+
// Navigate to workload menu
23+
await page.click('text=Workloads');
24+
25+
// Click visible Cronjob tab
26+
const cronjobTab = page.locator('role=option[name="Cronjob"]');
27+
await cronjobTab.waitFor({ state: 'visible', timeout: 30000 });
28+
await cronjobTab.click();
29+
30+
// Verify selected state
31+
await expect(cronjobTab).toHaveAttribute('aria-selected', 'true');
32+
await expect(page.locator('table')).toBeVisible({ timeout: 30000 });
33+
await page.click('button:has-text("Add")');
34+
await page.waitForSelector('[role="dialog"]', { timeout: 10000 });
35+
36+
// Listen for API calls
37+
const apiRequestPromise = page.waitForResponse(response => {
38+
return response.url().includes('/api/v1/_raw/CronJob') && response.status() === 200;
39+
}, { timeout: 15000 });
40+
41+
const testCronJobYaml = generateTestCronJobYaml();
42+
43+
// Set Monaco editor DOM content
44+
await page.evaluate((yaml) => {
45+
const textarea = document.querySelector('.monaco-editor textarea') as HTMLTextAreaElement;
46+
if (textarea) {
47+
textarea.value = yaml;
48+
textarea.focus();
49+
}
50+
}, testCronJobYaml);
51+
52+
/* eslint-disable */
53+
// Call React onChange callback to update component state
54+
await page.evaluate((yaml) => {
55+
56+
const findReactFiber = (element: any) => {
57+
const keys = Object.keys(element);
58+
return keys.find(key => key.startsWith('__reactFiber') || key.startsWith('__reactInternalInstance'));
59+
};
60+
61+
const monacoContainer = document.querySelector('.monaco-editor');
62+
if (monacoContainer) {
63+
const fiberKey = findReactFiber(monacoContainer);
64+
if (fiberKey) {
65+
let fiber = (monacoContainer as any)[fiberKey];
66+
67+
while (fiber) {
68+
if (fiber.memoizedProps && fiber.memoizedProps.onChange) {
69+
fiber.memoizedProps.onChange(yaml);
70+
return;
71+
}
72+
fiber = fiber.return;
73+
}
74+
}
75+
}
76+
77+
const dialog = document.querySelector('[role="dialog"]');
78+
if (dialog) {
79+
const fiberKey = findReactFiber(dialog);
80+
if (fiberKey) {
81+
let fiber = (dialog as any)[fiberKey];
82+
83+
const traverse = (node: any, depth = 0) => {
84+
if (!node || depth > 20) return false;
85+
86+
if (node.memoizedProps && node.memoizedProps.onChange) {
87+
node.memoizedProps.onChange(yaml);
88+
return true;
89+
}
90+
91+
if (node.child && traverse(node.child, depth + 1)) return true;
92+
if (node.sibling && traverse(node.sibling, depth + 1)) return true;
93+
94+
return false;
95+
};
96+
97+
traverse(fiber);
98+
}
99+
}
100+
}, testCronJobYaml);
101+
/* eslint-enable */
102+
103+
// Wait for submit button to become enabled
104+
await expect(page.locator('[role="dialog"] button:has-text("Submit")')).toBeEnabled();
105+
await page.click('[role="dialog"] button:has-text("Submit")');
106+
107+
// Wait for API call to succeed
108+
await apiRequestPromise;
109+
110+
// Wait for dialog to close
111+
await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => {
112+
// Dialog may already be closed
113+
});
114+
115+
// Verify new cronjob appears in list
116+
const cronJobName = getCronJobNameFromYaml(testCronJobYaml);
117+
118+
// Assert cronjob name exists
119+
expect(cronJobName).toBeTruthy();
120+
expect(cronJobName).toBeDefined();
121+
122+
try {
123+
await expect(page.locator('table').locator(`text=${cronJobName}`)).toBeVisible({
124+
timeout: 15000
125+
});
126+
} catch {
127+
// If not shown immediately in list, may be due to cache or refresh delay
128+
// But API success indicates cronjob was created
129+
}
130+
131+
// Cleanup: Delete the created cronjob
132+
try {
133+
await deleteK8sCronJob(cronJobName, 'default');
134+
} catch (error) {
135+
console.warn(`Failed to cleanup cronjob ${cronJobName}:`, error);
136+
}
137+
138+
// Debug
139+
if(process.env.DEBUG === 'true'){
140+
await page.screenshot({ path: 'debug-cronjob-create.png', fullPage: true });
141+
}
142+
143+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright 2025 The Karmada Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
import { test, expect } from '@playwright/test';
15+
import { setupDashboardAuthentication, generateTestCronJobYaml, createK8sCronJob, getCronJobNameFromYaml} from './test-utils';
16+
17+
test.beforeEach(async ({ page }) => {
18+
await setupDashboardAuthentication(page);
19+
});
20+
21+
test('should delete cronjob successfully', async ({ page }) => {
22+
// Create a test cronjob directly via kubectl to set up test data
23+
const testCronJobYaml = generateTestCronJobYaml();
24+
const cronJobName = getCronJobNameFromYaml(testCronJobYaml);
25+
26+
// Setup: Create cronjob using kubectl
27+
await createK8sCronJob(testCronJobYaml);
28+
29+
// Navigate to workload page
30+
await page.click('text=Workloads');
31+
32+
// Click visible Cronjob tab
33+
const cronjobTab = page.locator('role=option[name="Cronjob"]');
34+
await cronjobTab.waitFor({ state: 'visible', timeout: 30000 });
35+
await cronjobTab.click();
36+
37+
// Verify selected state
38+
await expect(cronjobTab).toHaveAttribute('aria-selected', 'true');
39+
await expect(page.locator('table')).toBeVisible({ timeout: 30000 });
40+
41+
// Wait for cronjob to appear in list
42+
const table = page.locator('table');
43+
await expect(table.locator(`text=${cronJobName}`)).toBeVisible({ timeout: 30000 });
44+
45+
// Find row containing test cronjob name
46+
const targetRow = page.locator(`table tbody tr:has-text("${cronJobName}")`);
47+
await expect(targetRow).toBeVisible({ timeout: 15000 });
48+
49+
// Find Delete button in that row and click
50+
const deleteButton = targetRow.locator('button[type="button"]').filter({ hasText: /^(Delete)$/ });
51+
await expect(deleteButton).toBeVisible({ timeout: 10000 });
52+
53+
// Listen for delete API call
54+
const deleteApiPromise = page.waitForResponse(response => {
55+
return response.url().includes('/_raw/cronjob') &&
56+
response.url().includes(`name/${cronJobName}`) &&
57+
response.request().method() === 'DELETE' &&
58+
response.status() === 200;
59+
}, { timeout: 15000 });
60+
61+
await deleteButton.click();
62+
63+
// Wait for delete confirmation tooltip to appear
64+
await page.waitForSelector('[role="tooltip"]', { timeout: 10000 });
65+
66+
// Click Confirm button to confirm deletion
67+
const confirmButton = page.locator('[role="tooltip"] button').filter({ hasText: /^(Confirm)$/ });
68+
await expect(confirmButton).toBeVisible({ timeout: 5000 });
69+
await confirmButton.click();
70+
71+
// Wait for delete API call to succeed
72+
await deleteApiPromise;
73+
74+
// Wait for tooltip to close
75+
await page.waitForSelector('[role="tooltip"]', { state: 'detached', timeout: 10000 }).catch(() => {});
76+
77+
// Refresh page to ensure UI is updated after deletion
78+
await page.reload();
79+
await page.click('text=Workloads');
80+
await expect(table).toBeVisible({ timeout: 30000 });
81+
82+
// Verify cronjob no longer exists in table
83+
await expect(table.locator(`text=${cronJobName}`)).toHaveCount(0, { timeout: 30000 });
84+
85+
// Debug
86+
if(process.env.DEBUG === 'true'){
87+
await page.screenshot({ path: 'debug-cronjob-delete-kubectl.png', fullPage: true });
88+
}
89+
});

0 commit comments

Comments
 (0)