import { test, expect, Page, APIRequestContext } from '@playwright/test';
import {
visitColocationConfigPage,
waitForCheck,
getSearchbox,
doNodeSearch,
getNodeName,
getNodeStatusColumn,
getColocationSwitches,
getColumnheaders,
getRowByNodeName,
doFilterStatus,
doToggleNodeColocation,
getNodeSwitchIsChecked,
getSyncButton,
doOpenModal,
getModal,
doCloseModal,
doClickModalConfirm,
doClickModalCancel,
getSwitches,
getUsagePluginSwitch,
doToggleUsagePluginSwitch,
getUsageThresholdSwitch,
doToggleUsageThresholdSwitch,
getUsageThresholdTextbox,
getEvictionSwitch,
doToggleEvictionSwitch,
getEvictionThresholdTextbox,
} from './utils/common';
import {
fetchAllNode,
fetchNode,
fetchColocationConfig,
fetchVolcanoConfig,
} from './utils/api';
import { COLOCATION_CONFIGS } from './constants';
import {
fetchColocationOptionsViaSsh,
fetchNodeViaSsh,
isSshConfigured,
withSsh,
} from './utils/ssh';
const getNodeNamesFromOptions = (options: Record<string, unknown>): string[] => {
const nodes = options.nodesConfig;
if (!Array.isArray(nodes)) {
return [];
}
return nodes
.map((node) => (node as { name?: unknown }).name)
.filter((name): name is string => typeof name === 'string');
};
const hasOversubLabels = (nodeData: Record<string, unknown>, oversubLabels: string[]): boolean => {
const status = (nodeData.status ?? {}) as Record<string, unknown>;
const capacity = (status.capacity ?? {}) as Record<string, string>;
const allocatable = (status.allocatable ?? {}) as Record<string, string>;
return (
oversubLabels.every((label) => label in capacity) &&
oversubLabels.every((label) => label in allocatable)
);
};
const COLOCATION_NODE_LABEL = 'node.openfuyao.com/colocation';
const hasColocationLabel = (nodeData: Record<string, unknown>): boolean => {
const metadata = (nodeData.metadata ?? {}) as Record<string, unknown>;
const labels = (metadata.labels ?? {}) as Record<string, string>;
return labels[COLOCATION_NODE_LABEL] === 'true';
};
const getVisibleNodeNameTexts = async (page: Page): Promise<string[]> => {
const nodeNames = await getNodeName(page);
const nodeNameTexts = await Promise.all(nodeNames.map((name) => name.textContent()));
return nodeNameTexts
.map((name) => (name ?? '').trim())
.filter((name) => name.length > 0);
};
const nodeNameCollator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' });
const sortNodeNames = (nodeNames: string[]): string[] => {
return [...nodeNames].sort((a, b) => nodeNameCollator.compare(a, b));
};
const isSameOrder = (a: string[], b: string[]): boolean => {
return a.length === b.length && a.every((value, index) => value === b[index]);
};
const getNodeCapabilityText = async (page: Page, nodeName: string): Promise<string> => {
const row = getRowByNodeName(page, nodeName);
if ((await row.count()) === 0) {
return '';
}
const capabilityCell = row.locator('td').nth(2);
if ((await capabilityCell.count()) === 0) {
return '';
}
return ((await capabilityCell.first().textContent()) ?? '').trim();
};
const pickToggleableNodeName = async (
page: Page,
apiContext: APIRequestContext,
): Promise<string> => {
const nodeList = (await fetchAllNode(apiContext)).items as Array<Record<string, unknown>>;
const nodeNames = nodeList
.map((node) => (node.metadata as Record<string, unknown> | undefined)?.name)
.filter((name): name is string => typeof name === 'string' && name.length > 0);
expect(nodeNames.length).toBeGreaterThan(0);
let selectedNodeName = '';
await waitForCheck('at least one node colocation switch should be enabled', async () => {
const diagnostics: string[] = [];
const syncButton = getSyncButton(page);
if ((await syncButton.count()) > 0 && (await syncButton.isVisible())) {
await syncButton.click();
await page.waitForLoadState('networkidle');
}
for (const nodeName of nodeNames) {
await doNodeSearch(page, nodeName);
const row = getRowByNodeName(page, nodeName);
if ((await row.count()) === 0) {
diagnostics.push(`${nodeName}:row-not-found`);
continue;
}
const switchElement = row.getByRole('switch').first();
if ((await switchElement.count()) === 0) {
diagnostics.push(`${nodeName}:switch-not-found`);
continue;
}
const capability = await getNodeCapabilityText(page, nodeName);
const disabled = await switchElement.isDisabled();
diagnostics.push(`${nodeName}:capability=${capability || 'unknown'}:${disabled ? 'disabled' : 'enabled'}`);
if (!disabled) {
selectedNodeName = nodeName;
return;
}
}
throw new Error(`all node switches are disabled; ${diagnostics.join('; ')}`);
}, 240000, 5000);
await doNodeSearch(page, selectedNodeName);
return selectedNodeName;
};
* @description Test colocation configuration page main interactions
*/
test.describe('混部策略配置页面 - 主要交互', () => {
let apiContext: APIRequestContext;
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page, request }: { page: Page; request: APIRequestContext }) => {
apiContext = request;
await visitColocationConfigPage(page);
});
* Test 43: Type content in searchbox
*/
test('【混部-043】搜索框可输入内容', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const testInput = 'test';
const searchbox = getSearchbox(page);
await searchbox.fill(testInput);
await expect(searchbox).toHaveValue(testInput);
});
* Test 44: Check search results
*/
test('【混部-044】搜索功能正确性验证', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const nodeList = (await fetchAllNode(apiContext)).items as Array<Record<string, unknown>>;
expect(nodeList.length).toBeGreaterThan(0);
const targetName = (nodeList[0].metadata as Record<string, string>).name;
const noMatchInput = 'qwertyuiop';
await doNodeSearch(page, noMatchInput);
const nodeSwitches = await getColocationSwitches(page);
expect(nodeSwitches).toHaveLength(0);
await doNodeSearch(page, targetName);
await waitForCheck(`search result should contain ${targetName}`, async () => {
const nodeNames = await getNodeName(page);
if (nodeNames.length === 0) {
throw new Error('node list is still empty');
}
for (const node of nodeNames) {
const nodeText = (await node.textContent()) ?? '';
if (!nodeText.includes(targetName)) {
throw new Error(`unexpected node in result: ${nodeText}`);
}
}
}, 30000, 1000);
});
* Test 45: Searchbox should limit input length
*/
test('【混部-045】搜索框输入长度限制', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const lengthLimit = 53;
const searchbox = getSearchbox(page);
await searchbox.fill('a'.repeat(255));
await expect(searchbox).toHaveValue('a'.repeat(lengthLimit));
});
* Test 48: Refresh button should trigger request
*/
test('【混部-048】点击刷新按钮触发请求', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await Promise.all([
page.waitForRequest((req) => req.url().includes(COLOCATION_CONFIGS) && req.method() === 'GET'),
getSyncButton(page).click(),
]);
});
* Test 49: Sort node name
*/
test('【混部-049】节点名称排序功能', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const headers = getColumnheaders(page);
await headers.name.click();
await waitForCheck('node list should be visible after sorting click', async () => {
const names = await getVisibleNodeNameTexts(page);
if (names.length === 0) {
throw new Error('node list is empty');
}
}, 30000, 500);
const firstOrder = await getVisibleNodeNameTexts(page);
expect(firstOrder.length).toBeGreaterThan(0);
if (firstOrder.length === 1) {
await headers.name.click();
const secondOrder = await getVisibleNodeNameTexts(page);
expect(secondOrder).toEqual(firstOrder);
return;
}
const sortedAsc = sortNodeNames(firstOrder);
const sortedDesc = [...sortedAsc].reverse();
const firstIsAsc = isSameOrder(firstOrder, sortedAsc);
const firstIsDesc = isSameOrder(firstOrder, sortedDesc);
expect(firstIsAsc || firstIsDesc).toBeTruthy();
await headers.name.click();
await waitForCheck('node name order should switch after second click', async () => {
const secondOrder = await getVisibleNodeNameTexts(page);
if (isSameOrder(sortedAsc, sortedDesc)) {
if (!isSameOrder(secondOrder, firstOrder)) {
throw new Error('node order changed unexpectedly when all names compare equal');
}
return;
}
const expectedOrder = firstIsAsc ? sortedDesc : sortedAsc;
if (!isSameOrder(secondOrder, expectedOrder)) {
throw new Error(`unexpected sort order after second click: ${secondOrder.join(',')}`);
}
}, 30000, 500);
});
* Test 50: Filter node status
*/
test('【混部-050】节点状态筛选功能', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await doFilterStatus(page, 'Active');
const nodeStatuses = await getNodeStatusColumn(page);
for (const status of nodeStatuses.slice(1)) {
const statusText = await status.textContent();
expect(statusText).toBe('Active');
}
});
* Test 51: All nodes are switched off by default
*/
test('【混部-051】默认所有节点混部开关关闭', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const nodeNames = await getNodeName(page);
const [colocationOptions] = await fetchColocationConfig(apiContext);
const expectedEnabledNodes = getNodeNamesFromOptions(colocationOptions as Record<string, unknown>);
for (const node of nodeNames) {
const nodeName = ((await node.textContent()) || '').trim();
if (!nodeName) {
continue;
}
const checked = await getNodeSwitchIsChecked(page, nodeName);
expect(checked).toBe(expectedEnabledNodes.includes(nodeName));
}
});
* Test 52, 53: Switch on/off nodes - front end and back end
*/
test('【混部-052/053】开启/关闭节点混部功能前后端验证', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const nodeName = await pickToggleableNodeName(page, apiContext);
if (await getNodeSwitchIsChecked(page, nodeName)) {
await doToggleNodeColocation(page, nodeName);
await waitForCheck(`normalize node ${nodeName} to disabled`, async () => {
if (await getNodeSwitchIsChecked(page, nodeName)) {
throw new Error('node switch is still on in UI');
}
const [options] = await fetchColocationConfig(apiContext);
const colocationNodes = getNodeNamesFromOptions(options as Record<string, unknown>);
if (colocationNodes.includes(nodeName)) {
throw new Error(`node ${nodeName} is still in colocation config`);
}
}, 90000, 3000);
}
await doToggleNodeColocation(page, nodeName);
await waitForCheck(`enable node ${nodeName} on UI and API`, async () => {
if (!(await getNodeSwitchIsChecked(page, nodeName))) {
throw new Error('node switch is still off in UI');
}
const [colocationOptions] = await fetchColocationConfig(apiContext);
const colocationNodes = getNodeNamesFromOptions(colocationOptions as Record<string, unknown>);
if (!colocationNodes.includes(nodeName)) {
throw new Error(`node ${nodeName} not found in colocation config`);
}
}, 90000, 3000);
if (isSshConfigured()) {
await withSsh(async (ssh) => {
await waitForCheck(`enable node ${nodeName} in configmap via ssh`, async () => {
const options = await fetchColocationOptionsViaSsh(ssh);
const colocationNodes = getNodeNamesFromOptions(options);
if (!colocationNodes.includes(nodeName)) {
throw new Error(`node ${nodeName} not found in ssh configmap data`);
}
}, 90000, 3000);
});
}
await doToggleNodeColocation(page, nodeName);
await waitForCheck(`disable node ${nodeName} on UI and API`, async () => {
if (await getNodeSwitchIsChecked(page, nodeName)) {
throw new Error('node switch is still on in UI');
}
const [updatedColocationOptions] = await fetchColocationConfig(apiContext);
const updatedColocationNodes = getNodeNamesFromOptions(updatedColocationOptions as Record<string, unknown>);
if (updatedColocationNodes.includes(nodeName)) {
throw new Error(`node ${nodeName} is still in colocation config`);
}
}, 90000, 3000);
if (isSshConfigured()) {
await withSsh(async (ssh) => {
await waitForCheck(`disable node ${nodeName} in configmap via ssh`, async () => {
const options = await fetchColocationOptionsViaSsh(ssh);
const colocationNodes = getNodeNamesFromOptions(options);
if (colocationNodes.includes(nodeName)) {
throw new Error(`node ${nodeName} is still in ssh configmap data`);
}
}, 90000, 3000);
});
}
});
test('【混部-025/026】混部与超卖功能验证', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const nodeName = await pickToggleableNodeName(page, apiContext);
const oversubLabels = ['kubernetes.io/be-cpu', 'kubernetes.io/be-memory'];
if (await getNodeSwitchIsChecked(page, nodeName)) {
await doToggleNodeColocation(page, nodeName);
await waitForCheck('colocation label should disappear via api (normalize)', async () => {
const nodeData = await fetchNode(apiContext, nodeName);
if (hasColocationLabel(nodeData)) {
throw new Error('colocation label is still present');
}
}, 120000, 3000);
}
await doToggleNodeColocation(page, nodeName);
await waitForCheck('colocation label should appear via api', async () => {
const nodeData = await fetchNode(apiContext, nodeName);
if (!hasColocationLabel(nodeData)) {
throw new Error('colocation label is not ready yet');
}
}, 120000, 3000);
await waitForCheck('oversub labels should appear via api', async () => {
const nodeData = await fetchNode(apiContext, nodeName);
if (!hasOversubLabels(nodeData, oversubLabels)) {
throw new Error('oversub labels are not ready yet');
}
}, 120000, 3000);
if (isSshConfigured()) {
await withSsh(async (ssh) => {
await waitForCheck('oversub labels should appear via ssh kubectl', async () => {
const nodeData = await fetchNodeViaSsh(ssh, nodeName);
if (!hasOversubLabels(nodeData, oversubLabels)) {
throw new Error('oversub labels are not ready on ssh side');
}
}, 120000, 3000);
});
}
await doToggleNodeColocation(page, nodeName);
await waitForCheck('colocation label should disappear via api', async () => {
const nodeData = await fetchNode(apiContext, nodeName);
if (hasColocationLabel(nodeData)) {
throw new Error('colocation label is still present');
}
}, 120000, 3000);
});
});
* @description Test colocation configuration operations
*/
test.describe('混部策略配置操作', () => {
let apiContext: APIRequestContext;
test.describe.configure({ mode: 'serial' });
const ensureUsagePluginEnabled = async (page: Page): Promise<void> => {
await waitForCheck('usage plugin switch should be checked', async () => {
if (await getUsagePluginSwitch(page).isChecked()) {
return;
}
await doToggleUsagePluginSwitch(page);
if (!(await getUsagePluginSwitch(page).isChecked())) {
throw new Error('usage plugin switch is still unchecked');
}
}, 30000, 1000);
};
const ensureEvictionEnabled = async (page: Page): Promise<void> => {
await waitForCheck('eviction switch should be checked', async () => {
if (await getEvictionSwitch(page).isChecked()) {
return;
}
await doToggleEvictionSwitch(page);
if (!(await getEvictionSwitch(page).isChecked())) {
throw new Error('eviction switch is still unchecked');
}
}, 30000, 1000);
};
const ensureEvictionDisabled = async (page: Page): Promise<void> => {
await waitForCheck('eviction switch should be unchecked', async () => {
if (!(await getEvictionSwitch(page).isChecked())) {
return;
}
await doToggleEvictionSwitch(page);
if (await getEvictionSwitch(page).isChecked()) {
throw new Error('eviction switch is still checked in modal');
}
}, 30000, 1000);
};
const waitBackendEvictionState = async (enabled: boolean, timeoutMs = 90000): Promise<void> => {
const label = enabled ? 'enabled' : 'disabled';
await waitForCheck(`eviction should be ${label} in backend`, async () => {
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
const current = Boolean(rubikOptions.eviction?.enable);
if (current !== enabled) {
throw new Error(`eviction backend state mismatch, expected ${enabled}, got ${current}`);
}
}, timeoutMs, 3000);
};
const ensureUsageThresholdEnabled = async (page: Page): Promise<void> => {
await waitForCheck('usage threshold switch should be checked', async () => {
if (await getUsageThresholdSwitch(page).isChecked()) {
return;
}
await doToggleUsageThresholdSwitch(page);
if (!(await getUsageThresholdSwitch(page).isChecked())) {
throw new Error('usage threshold switch is still unchecked');
}
}, 30000, 1000);
};
test.beforeEach(async ({ page, request }: { page: Page; request: APIRequestContext }) => {
apiContext = request;
await visitColocationConfigPage(page);
await doOpenModal(page);
});
test.afterEach(async ({ page }: { page: Page }) => {
try {
if (!(await getModal(page).isVisible())) {
await doOpenModal(page);
}
if (!(await getModal(page).isVisible())) {
return;
}
if (await getUsagePluginSwitch(page).isChecked()) {
await doToggleUsagePluginSwitch(page);
}
if (await getEvictionSwitch(page).isChecked()) {
await ensureEvictionDisabled(page);
}
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
} catch {
}
});
* Test 55: Open modal
*/
test('【混部-055】打开配置弹窗', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await expect(getModal(page)).toBeVisible();
});
* Test 57: Close modal
*/
test('【混部-057】关闭配置弹窗', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await doCloseModal(page);
await expect(getModal(page)).not.toBeVisible();
});
* Test 58: Cancel modal (verify state not changed)
*/
test('【混部-058】取消配置弹窗', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const switches = await getSwitches(page);
const initialStates = await Promise.all(switches.map((sw) => sw.isChecked()));
await switches[0].click();
await switches[1].click();
await doClickModalCancel(page);
await doOpenModal(page);
const newSwitches = await getSwitches(page);
const newStates = await Promise.all(newSwitches.map((sw) => sw.isChecked()));
expect(newStates).toEqual(initialStates);
});
* Test 62: Turn on usage plugin
*/
test('【混部-062】开启负载感知调度', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('usage plugin should be enabled in backend', async () => {
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
if (!volcanoSchedulerOptions.usagePlugin?.enable) {
throw new Error('usage plugin enable flag is false');
}
const volcanoSchedulerConfig = await fetchVolcanoConfig(apiContext);
const tiers = (volcanoSchedulerConfig.tiers || []) as Array<Record<string, unknown>>;
const hasPriority = tiers.some((tier) =>
((tier.plugins || []) as Array<Record<string, unknown>>).some((plugin) => plugin.name === 'priority')
);
const hasUsage = tiers.some((tier) =>
((tier.plugins || []) as Array<Record<string, unknown>>).some((plugin) => {
if (plugin.name !== 'usage' || !('arguments' in plugin)) {
return false;
}
const args = plugin.arguments as Record<string, unknown>;
const thresholds = args.thresholds as Record<string, unknown>;
return typeof thresholds?.cpu !== 'undefined' && typeof thresholds?.mem !== 'undefined';
})
);
if (!hasPriority || !hasUsage) {
throw new Error('volcano scheduler plugins are not ready yet');
}
}, 90000, 3000);
});
* Test 64: Usage threshold switch unchecked by default
*/
test('【混部-064】真实负载调度阈值开关默认关闭', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await expect(getUsageThresholdSwitch(page)).not.toBeChecked();
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.enable).toBeFalsy();
});
* Test 65: Toggle usage threshold switch to show/hide inputs
*/
test('【混部-065】切换真实负载调度阈值开关显示/隐藏输入框', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
const [cpuThresholdTextbox] = getUsageThresholdTextbox(page);
await expect(cpuThresholdTextbox).not.toBeVisible();
await doToggleUsageThresholdSwitch(page);
await expect(cpuThresholdTextbox).toBeVisible();
});
* Test 66, 71: Turn on usage threshold with default thresholds
*/
test('【混部-066/071】开启真实负载调度阈值并验证默认值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await ensureUsageThresholdEnabled(page);
const [cpuTextbox, memTextbox] = getUsageThresholdTextbox(page);
await expect(cpuTextbox).toHaveValue('60.0');
await expect(memTextbox).toHaveValue('60.0');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('usage threshold defaults should persist', async () => {
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
if (!volcanoSchedulerOptions.usagePlugin?.usageThreshold?.enable) {
throw new Error('usage threshold switch is not enabled in backend');
}
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.cpu).toBe(60);
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.memory).toBe(60);
}, 90000, 3000);
});
* Test 67, 72: Maximum usage threshold
*/
test('【混部-067/072】最大真实负载调度阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await ensureUsageThresholdEnabled(page);
const [cpuTextbox, memTextbox] = getUsageThresholdTextbox(page);
await cpuTextbox.fill('100');
await memTextbox.fill('100');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('usage threshold max values should persist', async () => {
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
if (!volcanoSchedulerOptions.usagePlugin?.usageThreshold?.enable) {
throw new Error('usage threshold switch is not enabled in backend');
}
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.cpu).toBe(100);
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.memory).toBe(100);
}, 90000, 3000);
});
* Test 68, 69, 73, 74: Invalid usage thresholds
*/
test('【混部-068/069/073/074】无效的真实负载调度阈值验证', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await ensureUsageThresholdEnabled(page);
let requestFired = false;
const handleRequest = (request: { url(): string; method(): string }) => {
if (request.url().includes(COLOCATION_CONFIGS) && request.method() === 'POST') {
requestFired = true;
}
};
page.on('request', handleRequest);
let errorMessages;
const [cpuTextbox, memTextbox] = getUsageThresholdTextbox(page);
await memTextbox.fill('50');
await cpuTextbox.fill('-1');
errorMessages = await page.locator('text=请输入0-100之间的浮点数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await cpuTextbox.fill('101');
errorMessages = await page.locator('text=请输入0-100之间的浮点数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
expect(requestFired).toBeFalsy();
requestFired = false;
const [cpuTextbox2, memTextbox2] = getUsageThresholdTextbox(page);
await cpuTextbox2.fill('50');
await memTextbox2.fill('-1');
errorMessages = await page.locator('text=请输入0-100之间的浮点数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await memTextbox2.fill('101');
errorMessages = await page.locator('text=请输入0-100之间的浮点数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
expect(requestFired).toBeFalsy();
requestFired = false;
const [cpuTextbox3, memTextbox3] = getUsageThresholdTextbox(page);
await cpuTextbox3.fill('83.333');
errorMessages = await page.locator('text=最多2位小数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await cpuTextbox3.fill('50');
await memTextbox3.fill('83.333');
errorMessages = await page.locator('text=最多2位小数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
expect(requestFired).toBeFalsy();
page.off('request', handleRequest);
});
* Test 70, 75: Valid CPU/Memory thresholds
*/
test('【混部-070/075】有效的CPU和内存阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await ensureUsageThresholdEnabled(page);
const [cpuTextbox, memTextbox] = getUsageThresholdTextbox(page);
await cpuTextbox.fill('83.33');
await memTextbox.fill('83.33');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('usage threshold custom values should persist', async () => {
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
if (!volcanoSchedulerOptions.usagePlugin?.usageThreshold?.enable) {
throw new Error('usage threshold switch is not enabled in backend');
}
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.cpu).toBe(83.33);
expect(volcanoSchedulerOptions.usagePlugin?.usageThreshold?.memory).toBe(83.33);
}, 90000, 3000);
});
* Test 63: Turn off usage plugin
*/
test('【混部-063】关闭负载感知调度', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureUsagePluginEnabled(page);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await new Promise((resolve) => setTimeout(resolve, 5000));
await doOpenModal(page);
await doToggleUsagePluginSwitch(page);
await waitForCheck('usage plugin switch should remain unchecked', async () => {
if (await getUsagePluginSwitch(page).isChecked()) {
throw new Error('usage plugin switch was reset to checked');
}
}, 10000, 500);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('usage plugin should be disabled in backend', async () => {
const [, volcanoSchedulerOptions] = await fetchColocationConfig(apiContext);
if (volcanoSchedulerOptions.usagePlugin?.enable) {
throw new Error('usage plugin is still enabled in backend');
}
}, 90000, 3000);
});
* Test 84: Eviction switch unchecked by default
*/
test('【混部-084】离线负载水位线驱逐开关默认关闭', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await expect(getEvictionSwitch(page)).not.toBeChecked();
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
expect(rubikOptions.eviction?.enable).toBeFalsy();
});
* Test 85: Toggle eviction switch to show/hide inputs
*/
test('【混部-085】切换离线负载水位线驱逐开关显示/隐藏输入框', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
if (await getEvictionSwitch(page).isChecked()) {
await doToggleEvictionSwitch(page);
}
const [cpuTextbox] = getEvictionThresholdTextbox(page);
await expect(cpuTextbox).not.toBeVisible();
await doToggleEvictionSwitch(page);
await expect(cpuTextbox).toBeVisible();
});
* Test 86: Turn on eviction
*/
test('【混部-086】开启离线负载水位线驱逐', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureEvictionEnabled(page);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitBackendEvictionState(true);
});
* Test 88, 94: Default eviction thresholds
*/
test('【混部-088/094】默认驱逐阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
const [, , rubikOptionsBefore] = await fetchColocationConfig(apiContext);
const expectedCpu = rubikOptionsBefore.eviction?.cpuevict?.threshold ?? 60;
const expectedMem = rubikOptionsBefore.eviction?.memoryevict?.threshold ?? 60;
await ensureEvictionEnabled(page);
const [cpuTextbox, memTextbox] = getEvictionThresholdTextbox(page);
await expect(cpuTextbox).toBeVisible();
await expect(memTextbox).toBeVisible();
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('eviction default thresholds should persist', async () => {
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
if (!rubikOptions.eviction?.enable) {
throw new Error('eviction is not enabled in backend');
}
expect(rubikOptions.eviction?.cpuevict?.threshold).toBe(expectedCpu);
expect(rubikOptions.eviction?.memoryevict?.threshold).toBe(expectedMem);
}, 90000, 3000);
});
* Test 89, 95: Minimum eviction thresholds
*/
test('【混部-089/095】最小驱逐阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureEvictionEnabled(page);
const [cpuTextbox, memTextbox] = getEvictionThresholdTextbox(page);
await cpuTextbox.fill('1');
await memTextbox.fill('1');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('eviction min thresholds should persist', async () => {
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
if (!rubikOptions.eviction?.enable) {
throw new Error('eviction is not enabled in backend');
}
expect(rubikOptions.eviction?.cpuevict?.threshold).toBe(1);
expect(rubikOptions.eviction?.memoryevict?.threshold).toBe(1);
}, 90000, 3000);
});
* Test 90, 96: Maximum eviction thresholds
*/
test('【混部-090/096】最大驱逐阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureEvictionEnabled(page);
const [cpuTextbox, memTextbox] = getEvictionThresholdTextbox(page);
await cpuTextbox.fill('99');
await memTextbox.fill('99');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('eviction max thresholds should persist', async () => {
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
if (!rubikOptions.eviction?.enable) {
throw new Error('eviction is not enabled in backend');
}
expect(rubikOptions.eviction?.cpuevict?.threshold).toBe(99);
expect(rubikOptions.eviction?.memoryevict?.threshold).toBe(99);
}, 90000, 3000);
});
* Test 91, 92, 97, 98: Invalid eviction thresholds
*/
test('【混部-091/092/097/098】无效的驱逐阈值验证', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureEvictionEnabled(page);
let requestFired = false;
const handleRequest = (request: { url(): string; method(): string }) => {
if (request.url().includes(COLOCATION_CONFIGS) && request.method() === 'POST') {
requestFired = true;
}
};
page.on('request', handleRequest);
let errorMessages;
const [cpuTextbox, memTextbox] = getEvictionThresholdTextbox(page);
await memTextbox.fill('50');
await cpuTextbox.fill('0');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await cpuTextbox.fill('100');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await cpuTextbox.fill('50.5');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
expect(requestFired).toBeFalsy();
const [cpuTextbox2, memTextbox2] = getEvictionThresholdTextbox(page);
await memTextbox2.fill('0');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await memTextbox2.fill('100');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await memTextbox2.fill('50.5');
errorMessages = await page.locator('text=请输入0-100之间的整数').all();
expect(errorMessages.length).toBeGreaterThan(0);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
expect(requestFired).toBeFalsy();
page.off('request', handleRequest);
});
* Test 93, 99: Valid eviction thresholds
*/
test('【混部-093/099】有效的驱逐阈值', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
await ensureEvictionEnabled(page);
const [cpuTextbox, memTextbox] = getEvictionThresholdTextbox(page);
await cpuTextbox.fill('50');
await memTextbox.fill('50');
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitForCheck('eviction custom thresholds should persist', async () => {
const [, , rubikOptions] = await fetchColocationConfig(apiContext);
if (!rubikOptions.eviction?.enable) {
throw new Error('eviction is not enabled in backend');
}
expect(rubikOptions.eviction?.cpuevict?.threshold).toBe(50);
expect(rubikOptions.eviction?.memoryevict?.threshold).toBe(50);
}, 90000, 3000);
});
* Test 87: Turn off eviction
*/
test('【混部-087】关闭离线负载水位线驱逐', {
tag: ['@v25.06', '@colocation'],
}, async ({ page }: { page: Page }) => {
test.setTimeout(5 * 60 * 1000);
await ensureEvictionEnabled(page);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitBackendEvictionState(true);
await doOpenModal(page);
await waitForCheck('eviction switch should load as checked before disabling', async () => {
if (!(await getEvictionSwitch(page).isChecked())) {
throw new Error('eviction switch has not reflected enabled backend state yet');
}
}, 30000, 1000);
await ensureEvictionDisabled(page);
await doClickModalConfirm(page);
await page.waitForLoadState('networkidle');
await waitBackendEvictionState(false, 120000);
});
});