import { expect, Page } from "@playwright/test";
import { NodeSSH } from "node-ssh";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
type UninstallOptions = {
strict?: boolean;
quickCheckTimeoutMs?: number;
};
const __applicationDir = path.dirname(fileURLToPath(import.meta.url));
const helloWorldPackagePath = path.resolve(__applicationDir, "../test-assets/hello-world-0.1.0.tgz");
function getHelloWorldPackagePath() {
return helloWorldPackagePath;
}
async function goToMarketplacePackageManagement(page: Page) {
await page.getByText("应用市场", { exact: true }).click();
await page.getByRole("link", { name: "仓库配置" }).click();
await expect(page).toHaveURL(/\/container_platform\/appMarket\/stash\/wareHouse$/, { timeout: 60_000 });
await page.getByRole("tab", { name: "包管理" }).click();
}
async function prepareHelloWorldPackageLikeMarketplace(page: Page) {
const packagePath = getHelloWorldPackagePath();
if (!fs.existsSync(packagePath)) {
throw new Error(`hello-world package not found at ${packagePath}`);
}
await goToMarketplacePackageManagement(page);
await page.getByRole("button", { name: "添加包" }).click();
await page.locator("input[type='file']").setInputFiles(packagePath);
await expect(page.getByText("hello-world-0.1.0.tgz")).toBeVisible({ timeout: 120_000 });
await page.getByRole("button", { name: "确 定" }).click();
await expect(page.getByText("文件上传成功")).toBeVisible({ timeout: 120_000 });
return packagePath;
}
function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function selectAppMarketSource(page: Page, source: string) {
const defaultSource = page.getByRole("checkbox", { name: "openFuyao" }).first();
if (source !== "openFuyao" && (await defaultSource.count()) > 0 && (await defaultSource.isVisible().catch(() => false))) {
await defaultSource.uncheck().catch(() => {});
}
const sourceCheckbox = page.getByRole("checkbox", { name: source }).first();
if ((await sourceCheckbox.count()) > 0 && (await sourceCheckbox.isVisible().catch(() => false))) {
await sourceCheckbox.check();
}
}
async function goToAppMarket(page: Page) {
const origin = (() => {
try {
return new URL(page.url()).origin;
} catch {
return "";
}
})();
if (origin) {
await page.goto(origin + "/container_platform/appMarket/marketCategory");
} else {
await page.getByText("应用市场", { exact: true }).click();
}
await expect(page).toHaveURL(/\/container_platform\/appMarket\/marketCategory/, { timeout: 60_000 });
}
async function installComponent(page: Page, componentName: string, appName: string, version?: string, source = "openFuyao") {
const origin = (() => {
try {
return new URL(page.url()).origin;
} catch {
return "";
}
})();
if (origin) {
await page.goto(origin + "/container_platform/appMarket/marketCategory");
}
await page.getByRole("link", { name: "应用列表" }).click();
const searchBox = page.getByRole('textbox', { name: /搜索.*名称/ }).first();
await expect(searchBox).toBeVisible({ timeout: 60_000 });
if (source) {
await selectAppMarketSource(page, source);
}
await searchBox.fill(componentName);
await page.getByRole('button', { name: 'search' }).click();
const componentTitle = page
.locator("#root_container .application_item_title")
.filter({ hasText: new RegExp(`^${escapeRegExp(componentName)}$`) })
.first();
await expect(componentTitle).toBeVisible({ timeout: 60_000 });
await componentTitle.click();
if (version) {
await page.waitForTimeout(10_000);
const selectedVersion = page.getByText(/版本:\s*/).first();
await expect(selectedVersion).toBeVisible({ timeout: 60_000 });
await selectedVersion.click();
await page.getByText(`版本: ${version}`, { exact: true }).click();
}
await page.getByRole('button', { name: '部 署' }).click();
const appNameInput = page.getByRole('textbox', { name: '* 应用名称' });
const deployRiskCheckbox = page.getByRole('checkbox', { name: /我已了解并确认上述风险/ }).first();
if ((await deployRiskCheckbox.count()) > 0 && (await deployRiskCheckbox.isVisible().catch(() => false))) {
await deployRiskCheckbox.check();
await page.getByRole('button', { name: /继续部署/ }).first().click();
}
await appNameInput.fill(appName);
const namespaceCombobox = page.getByRole('combobox', { name: /命名空间/i }).first();
await expect(namespaceCombobox).toBeVisible({ timeout: 60_000 });
await namespaceCombobox.click({ force: true });
const namespaceListbox = page.locator('#form_in_modal_nameSpace_list');
if (!(await namespaceListbox.isVisible().catch(() => false))) {
await namespaceCombobox.focus();
await page.keyboard.press('ArrowDown');
}
const defaultNamespaceOption = page.getByTitle('bke-cluster').first();
await expect(defaultNamespaceOption).toBeVisible({ timeout: 10_000 });
await defaultNamespaceOption.click();
const confirmButton = page.getByRole('button', { name: /确\s*定/ });
await expect(confirmButton).toBeVisible({ timeout: 60_000 });
await expect(confirmButton).toBeEnabled({ timeout: 60_000 });
await confirmButton.click();
};
async function checkComponent(
page: Page,
appName: string,
options?: {
entry?: "extendManage" | "applicationManageHelm";
serviceClusterUrl?: string;
},
) {
const entry = options?.entry ?? "extendManage";
if (entry === "applicationManageHelm") {
await expect(page.getByText("部署成功,即将跳转应用管理页面")).toBeVisible({ timeout: 120_000 });
await expect(page.getByRole('navigation').getByText('应用管理')).toBeVisible();
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(appName);
await page.getByRole('button', { name: 'search' }).click();
await expect(page.getByRole('cell', { name: 'check-circle 部署成功' })).toBeVisible();
} else {
await expect(page.getByText('部署成功,即将跳转扩展组件管理页面')).toBeVisible({ timeout: 120_000 });
await expect(page.getByRole('navigation').getByText('扩展组件管理')).toBeVisible();
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(appName);
await page.getByRole('button', { name: 'search' }).click();
await expect(page.getByRole('cell', { name: 'check-circle 部署成功' })).toBeVisible();
}
};
async function changeString(page: Page, oldStr: string, newStr: string) {
await page.getByText(oldStr).first().click();
await page.keyboard.press('Shift+Home');
await page.keyboard.press('Backspace');
await page.keyboard.type(newStr);
await expect(page.getByText(newStr).first()).toBeVisible();
}
type HelmRepoInfo = { name: string; url: string };
async function changePullPolicyLineWithWait(page: Page): Promise<{ oldLine: string; newLine: string }> {
let lineLocator = page.getByText(/pullPolicy:\s*(Always|IfNotPresent)/).first();
let ready = false;
for (let i = 0; i < 12; i++) {
await page.getByRole("tab", { name: "Values.yaml" }).click().catch(() => {});
lineLocator = page.getByText(/pullPolicy:\s*(Always|IfNotPresent)/).first();
const found = await lineLocator
.waitFor({ state: "visible", timeout: 5000 })
.then(() => true)
.catch(() => false);
if (found) {
ready = true;
break;
}
}
expect(ready, "Values.yaml 未加载出 pullPolicy 字段").toBeTruthy();
await expect(lineLocator).toBeVisible({ timeout: 60_000 });
const raw = ((await lineLocator.textContent()) ?? "").replace(/\u00a0/g, " ");
const singleLine = raw.replace(/\r?\n/g, "").trimEnd();
const matched = singleLine.match(/^(\s*)pullPolicy:\s*(Always|IfNotPresent)\s*$/);
expect(matched, "无法解析 pullPolicy 行").not.toBeNull();
const indent = matched![1];
const current = matched![2];
const next = current === "Always" ? "IfNotPresent" : "Always";
const oldLine = `${indent}pullPolicy: ${current}`;
const newLine = `${indent}pullPolicy: ${next}`;
await lineLocator.click();
await page.keyboard.press("Shift+Home");
await page.keyboard.press("Backspace");
await page.keyboard.type(newLine);
await expect(page.getByText(new RegExp(`pullPolicy:\\s*${next}`)).first()).toBeVisible({ timeout: 60_000 });
return { oldLine, newLine };
}
async function selectLog(page: Page, defaultContainer: string, selectContainer: string) {
await page.getByRole('tab', { name: '日志' }).click();
await page.getByText(defaultContainer + '-').click();
await page.getByTitle(selectContainer + '-').locator('div').click();
await page.getByRole('button', { name: /查\s*询/ }).click();
}
async function waitForLogReady(page: Page) {
const emptyLogLocator = page.getByText("暂无日志");
await expect
.poll(async () => {
const hasEmptyLog = await emptyLogLocator
.count()
.then((count: number) => count > 0 && emptyLogLocator.first().isVisible())
.catch(() => false);
if (!hasEmptyLog) {
return true;
}
await page.getByRole("button", { name: /查\s*询/ }).first().click().catch(() => {});
return false;
}, {
timeout: 60_000,
intervals: [1000, 2000, 5000],
})
.toBeTruthy();
}
async function verifyLogExport(page: Page, outputDir: string) {
await waitForLogReady(page);
const exportBtn = page.getByText("导出").first();
const exportIcon = page.getByRole("img", { name: "export" }).locator("svg");
const triggerExport = async () => {
if (await exportBtn.count()) {
await exportBtn.click();
} else {
await exportIcon.click();
}
};
const download = await Promise.all([
page.waitForEvent("download", { timeout: 60_000 }),
triggerExport(),
]).then(([d]) => d);
await expect(page.getByText("导出成功")).toBeVisible({ timeout: 60_000 });
const filename = download.suggestedFilename();
expect(filename).toBeTruthy();
const savePath = path.join(outputDir, filename);
await download.saveAs(savePath);
expect(fs.statSync(savePath).size).toBeGreaterThan(0);
}
async function uninstallComponent(page: Page, appName: string, opts: UninstallOptions = {}) {
const { strict = true, quickCheckTimeoutMs = 3_000 } = opts;
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(appName);
await page.getByRole('button', { name: 'search' }).click();
const targetRow = page.getByRole('row').filter({ hasText: appName }).first();
if (!strict) {
const start = Date.now();
let found = false;
while (Date.now() - start < quickCheckTimeoutMs) {
found = (await targetRow.count()) > 0;
if (found) break;
await page.waitForTimeout(300);
}
if (!found) return false;
} else {
await expect(targetRow).toBeVisible({ timeout: quickCheckTimeoutMs });
}
await targetRow.locator('[data-icon="more"]').first().click();
await page.getByRole('button', { name: '卸载' }).click();
await expect(page.getByText('卸载扩展组件', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: '已了解卸载操作无法恢复' }).check();
await page.getByRole('button', { name: '卸 载' }).click();
await expect(page.getByText('扩展组件' + appName + '卸载成功')).toBeVisible();
await expect(page.getByRole('cell', { name: 'Simple Empty 暂无数据' })).toBeVisible();
return true;
};
async function uninstallHelmComponent(page: Page, appName: string, opts: UninstallOptions = {}) {
const { strict = true, quickCheckTimeoutMs = 3_000 } = opts;
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(appName);
await page.getByRole('button', { name: 'search' }).click();
const targetRow = page.getByRole('row').filter({ hasText: appName }).first();
if (!strict) {
const start = Date.now();
let found = false;
while (Date.now() - start < quickCheckTimeoutMs) {
found = (await targetRow.count()) > 0;
if (found) break;
await page.waitForTimeout(300);
}
if (!found) return false;
} else {
await expect(targetRow).toBeVisible({ timeout: quickCheckTimeoutMs });
}
await targetRow.locator('[data-icon="more"]').first().click();
await page.getByRole('button', { name: '卸载' }).click();
await expect(page.getByText('卸载应用', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: '已了解卸载操作无法恢复' }).check();
await page.getByRole('button', { name: '卸 载' }).click();
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(appName);
await page.getByRole('button', { name: 'search' }).click();
await expect(page.getByRole('row').filter({ hasText: appName })).toHaveCount(0);
return true;
}
async function upgradeComponent(page: Page, initVersion: string, upgradeVersion: string) {
await page.getByRole('list').getByText('详情').click();
await page.getByRole('button', { name: '操作 down' }).click();
await page.getByRole('button', { name: '升级' }).click();
const initVersionLoc = page.getByTitle(initVersion, { exact: true });
await expect(initVersionLoc).toBeVisible({ timeout: 60_000 });
await initVersionLoc.click();
await page.getByTitle(upgradeVersion, { exact: true }).click();
};
async function revertComponent(page: Page, revertName: string) {
const actionButton = page.getByRole('button', { name: '操作 down' }).first();
const detailLink = page.getByRole('link', { name: '详情' }).first();
const detailListItem = page.getByRole('list').getByText('详情').first();
if ((await actionButton.count()) === 0) {
if ((await detailLink.count()) > 0) {
await detailLink.click();
} else if ((await detailListItem.count()) > 0) {
await detailListItem.click();
}
}
await expect(actionButton).toBeVisible({ timeout: 60_000 });
await page.getByRole('button', { name: '操作 down' }).click();
await page.getByRole('button', { name: '回退' }).click();
await expect(page.getByText('应用回退')).toBeVisible();
await page.waitForTimeout(60_000);
const revertRowFirstCell = page.getByRole('row', { name: revertName }).getByRole('cell').first();
await expect(revertRowFirstCell).toBeVisible({ timeout: 60_000 });
await revertRowFirstCell.click();
await page.getByRole('button', { name: /确\s*定/ }).click();
await expect(page.getByText('回退版本成功')).toBeVisible({ timeout: 60_000 });
};
async function checkPrefixResourceNum(page: Page, resource: string, prefix: string, state: string, stateNum: number) {
await page.getByText('工作负载').click();
await page.getByRole('link', { name: resource }).click();
await page.getByRole('textbox', { name: /搜索.*名称/ }).fill(prefix);
await page.getByRole('button', { name: 'search' }).click();
await expect(page.getByText(state)).toHaveCount(stateNum);
};
async function gotoApplicationResourceTab(page: Page, serviceClusterUrl: string, appName: string) {
await page.goto(serviceClusterUrl + "/container_platform/extendManage");
await page.getByRole("textbox", { name: /搜索.*名称/ }).fill(appName);
await page.getByRole("button", { name: "search" }).click();
await page.getByRole("link", { name: appName }).click();
await page.getByRole("tab", { name: "资源" }).click();
await expect(page.getByRole("table")).toBeVisible();
}
async function clickSortHeader(page: Page, headerName: string) {
const header = page.getByRole("columnheader", { name: new RegExp(headerName) }).first();
await expect(header).toBeVisible();
const before = await header.getAttribute("aria-sort");
await header.click();
if (before) {
await expect
.poll(async () => await header.getAttribute("aria-sort"), { timeout: 60_000 })
.not.toBe(before);
} else {
await expect.poll(async () => await header.getAttribute("aria-sort"), { timeout: 60_000 }).not.toBeNull();
}
}
async function getColumnTexts(page: Page, columnIndex: number): Promise<string[]> {
const cells = page.locator(`tbody tr td:nth-child(${columnIndex})`);
const texts = await cells.allTextContents();
return texts.map((t) => (t ?? "").trim()).filter((t) => t.length > 0);
}
function expectSortedAsc(values: string[], message = "排序结果不是升序") {
const normalized = values.map((v) => v.trim().toLowerCase());
const sorted = [...normalized].sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
expect(normalized, message).toEqual(sorted);
}
function expectSortedDesc(values: string[], message = "排序结果不是降序") {
const normalized = values.map((v) => v.trim().toLowerCase());
const sorted = [...normalized].sort((a, b) => b.localeCompare(a, "zh-Hans-CN"));
expect(normalized, message).toEqual(sorted);
}
async function expectDefaultSortState(page: Page, headerName: string, sortState: "ascending" | "descending") {
const header = page.getByRole("columnheader", { name: new RegExp(headerName) }).first();
await expect(header).toHaveAttribute("aria-sort", sortState);
}
async function verifyOverviewSort(
page: Page,
options: { nameHeader: string; nameColIndex: number; updateTimeHeader: string; updateTimeColIndex: number },
) {
const { nameHeader, nameColIndex, updateTimeHeader, updateTimeColIndex } = options;
const rows = page.locator("table tbody tr");
await expect.poll(async () => rows.count(), { timeout: 60_000 }).toBeGreaterThanOrEqual(2);
await clickSortHeader(page, nameHeader);
const nameAsc = await getColumnTexts(page, nameColIndex);
expectSortedAsc(nameAsc, `${nameHeader}第一次点击后升序排序结果不正确`);
await clickSortHeader(page, nameHeader);
const nameDesc = await getColumnTexts(page, nameColIndex);
expectSortedDesc(nameDesc, `${nameHeader}第二次点击后降序排序结果不正确`);
await clickSortHeader(page, updateTimeHeader);
const timeAsc = await getColumnTexts(page, updateTimeColIndex);
expectSortedAsc(timeAsc, `${updateTimeHeader}第一次点击后升序排序结果不正确`);
await clickSortHeader(page, updateTimeHeader);
const timeDesc = await getColumnTexts(page, updateTimeColIndex);
expectSortedDesc(timeDesc, `${updateTimeHeader}第二次点击后降序排序结果不正确`);
}
async function verifyResourceSortInResourceTab(page: Page, typeColumnIndex = 3) {
const rows = page.locator("table tbody tr");
await expect.poll(async () => rows.count(), { timeout: 60_000 }).toBeGreaterThanOrEqual(2);
await clickSortHeader(page, "资源名称");
const resourceNameAsc = await getColumnTexts(page, 1);
expectSortedAsc(resourceNameAsc, "资源名称第一次点击后升序排序结果不正确");
await clickSortHeader(page, "资源名称");
const resourceNameDesc = await getColumnTexts(page, 1);
expectSortedDesc(resourceNameDesc, "资源名称第二次点击后降序排序结果不正确");
await clickSortHeader(page, "类型");
const typeAsc = await getColumnTexts(page, typeColumnIndex);
expectSortedAsc(typeAsc, "类型第一次点击后升序排序结果不正确");
await clickSortHeader(page, "类型");
const typeDesc = await getColumnTexts(page, typeColumnIndex);
expectSortedDesc(typeDesc, "类型第二次点击后降序排序结果不正确");
}
const serviceClusterUrl = process.env.TEST_FUYAOURL;
async function openYamlQuickDeploy(page: any) {
await page.goto(serviceClusterUrl + "/container_platform/appMarket/marketCategory");
await page.getByRole("link", { name: "应用列表" }).click();
await page.getByRole("img", { name: "appstore-add" }).locator("svg").click();
await page.getByRole("button", { name: "前往部署" }).click();
await expect(page).toHaveURL(/\/container_platform\/appMarket\/oneClickDeploy/);
await expect(page.getByRole("button", { name: "部 署" })).toBeVisible();
}
async function inputYamlContent(page: any, yamlContent: string) {
const genericTextbox = page.getByRole("textbox").first();
await expect(genericTextbox).toBeVisible({ timeout: 60_000 });
console.info("[yaml-input] strategy=textbox-fallback");
await genericTextbox.click();
await page.keyboard.press("Control+A");
await page.keyboard.insertText(yamlContent);
}
async function deleteDeploymentByName(page: any, deploymentName: string) {
if (serviceClusterUrl) {
await page.goto(serviceClusterUrl + "/container_platform/workload/deployment");
} else {
await page.getByText("工作负载").click();
await page.getByRole("link", { name: "Deployment", exact: true }).click();
}
await expect(page).toHaveURL(/\/container_platform\/workload\/deployment/);
const refreshBtn = page.getByRole("button", { name: "sync" }).first();
if ((await refreshBtn.count()) > 0) {
await refreshBtn.click().catch(() => {});
}
const searchInputCandidates = [
page.getByRole("textbox", { name: /搜索.*名称/ }).first(),
page.getByRole("textbox").first(),
];
let searchInput = searchInputCandidates[0];
for (const candidate of searchInputCandidates) {
if ((await candidate.count()) > 0) {
searchInput = candidate;
break;
}
}
await expect(searchInput).toBeVisible({ timeout: 60_000 });
const searchDeployment = async () => {
await searchInput.fill(deploymentName);
await searchInput.press("Enter");
await page.waitForTimeout(500);
};
await searchDeployment();
const targetNameCell = page.getByRole("cell", { name: deploymentName, exact: true }).first();
await expect(targetNameCell).toBeVisible({ timeout: 60_000 });
await expect
.poll(
async () => {
if ((await refreshBtn.count()) > 0) {
await refreshBtn.click().catch(() => {});
}
await searchDeployment();
const currentRow = page.getByRole("row").filter({ hasText: deploymentName }).first();
const rowCount = await currentRow.count();
if (rowCount === 0) return "";
return (await currentRow.textContent()) ?? "";
},
{ timeout: 60_000, message: `Deployment ${deploymentName} 未收敛到可操作状态` }
)
.toMatch(/Active|Running/);
await page.getByRole("columnheader", { name: "操作" }).click().catch(() => {});
const actionRow = page.getByRole("row", { name: deploymentName }).first();
await expect(actionRow).toBeVisible({ timeout: 60_000 });
const rowActionIcon = actionRow.locator('[data-icon="more"]').first();
if ((await rowActionIcon.count()) > 0) {
await rowActionIcon.click();
} else {
await actionRow.locator("svg").first().click();
}
await page.getByRole("button", { name: "删除" }).click();
const deleteDialog = page.getByRole("dialog").last();
const confirmCheckbox = deleteDialog.getByRole("checkbox", { name: "已了解删除操作无法恢复" });
await expect(confirmCheckbox).toBeVisible({ timeout: 60_000 });
await confirmCheckbox.check();
await deleteDialog.getByRole("button", { name: "删 除" }).click();
await expect
.poll(
async () => {
await searchDeployment();
return await page.getByRole("cell", { name: deploymentName, exact: true }).count();
},
{ timeout: 60_000, message: `Deployment ${deploymentName} 删除后仍存在` }
)
.toBe(0);
}
const normalizeSpaces = (text: string) => (text ?? "").replace(/\s+/g, " ").trim();
async function withKubectlSsh<T>(fn: (ssh: NodeSSH) => Promise<T>): Promise<T> {
const TEST_SSH_ADDRESS = process.env.TEST_NODE2_IP;
const TEST_SSH_PASSWORD = process.env.TEST_NODE2_PASSWORD;
const TEST_SSH_USERNAME = "root";
expect(TEST_SSH_ADDRESS, "缺少环境变量 TEST_NODE2_IP").toBeTruthy();
expect(TEST_SSH_PASSWORD, "缺少环境变量 TEST_NODE2_PASSWORD").toBeTruthy();
const ssh = new NodeSSH();
await ssh.connect({
host: TEST_SSH_ADDRESS!,
username: TEST_SSH_USERNAME,
password: TEST_SSH_PASSWORD!,
});
try {
return await fn(ssh);
} finally {
ssh.dispose();
}
}
async function execKubectl(
ssh: NodeSSH,
command: string,
): Promise<{ stdout: string; stderr: string; code?: number }> {
const result = await ssh.execCommand(command);
const code = typeof result.code === "number" ? result.code : undefined;
return {
stdout: (result.stdout ?? "").toString(),
stderr: (result.stderr ?? "").toString(),
code,
};
}
async function kubectlGetFirstPodByNamePrefix(
ssh: NodeSSH,
namespace: string,
podNamePrefix: string,
): Promise<{ namespace: string; podName: string }> {
const maxRetry = 30;
const intervalMs = 2000;
for (let i = 0; i < maxRetry; i++) {
const { stdout, code } = await execKubectl(
ssh,
`kubectl get pods -n ${namespace} --no-headers -o custom-columns=NAME:.metadata.name`,
);
if (code === 0) {
const names = stdout
.trim()
.split(/\s+/)
.filter(Boolean);
const match =
names.find((n) => n.startsWith(podNamePrefix)) ??
names.find((n) => n.includes(podNamePrefix));
if (match) return { namespace, podName: match };
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`未找到命名空间=${namespace} 下 pod 前缀=${podNamePrefix} 的 pod(尝试 ${maxRetry} 次后仍为空)`);
}
async function getUiLogText(page: any): Promise<string> {
const candidates = [
page.locator("pre").first(),
page.locator("code").first(),
page.locator("textarea").first(),
page.locator('[class*="log"]').first(),
page.locator('[class*="Log"]').first(),
];
for (const loc of candidates) {
if ((await loc.count()) === 0) continue;
const t = await loc.textContent();
if (t && normalizeSpaces(t).length > 0 && !normalizeSpaces(t).includes("暂无日志")) {
return t;
}
}
const bodyText = await page.locator("body").innerText();
const emptyLoc = page.getByText("暂无日志");
const emptyVisible = await emptyLoc.isVisible().catch(() => false);
if (emptyVisible) return "";
return bodyText;
}
async function verifyUiLogWithKubectl(
page: any,
ssh: NodeSSH,
params: {
kubectlNamespace: string;
kubectlPodNamePrefix: string;
kubectlContainerName?: string;
},
) {
const { namespace, podName } = await kubectlGetFirstPodByNamePrefix(
ssh,
params.kubectlNamespace,
params.kubectlPodNamePrefix,
);
const isHeaderLikeLine = (line: string) => /Pod/.test(line) && /容器/.test(line);
const extractMsgLikeFragments = (line: string): string[] => {
const n = normalizeSpaces(line);
if (!n || n.length < 8) return [];
const out = new Set<string>();
for (const key of ["msg", "message"]) {
const m = n.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`));
if (m?.[1]) out.add(m[1]);
}
const mLogfmt = n.match(/\b(?:msg|message)\s*=\s*"([^"]+)"/);
if (mLogfmt?.[1]) out.add(mLogfmt[1]);
const mGoQuoted = n.match(/]\s*"([^"]+)"/);
if (mGoQuoted?.[1]) out.add(mGoQuoted[1]);
const quoted = Array.from(n.matchAll(/"([^"]{10,})"/g)).map((m) => m[1]);
for (const q of quoted) out.add(q);
if (!isHeaderLikeLine(n)) {
const tokens = Array.from(n.matchAll(/[A-Za-z0-9][A-Za-z0-9._-]{8,}/g)).map((m) => m[0]);
for (const t of tokens) out.add(t);
}
return Array.from(out);
};
const buildCandidateKeywords = (uiText: string): string[] => {
const lines = (uiText ?? "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const fragments: string[] = [];
for (const l of lines) fragments.push(...extractMsgLikeFragments(l));
if (lines.length <= 1) fragments.push(...extractMsgLikeFragments(uiText));
const normalized = fragments
.map((s) => normalizeSpaces(String(s)))
.filter((s) => s.length >= 8)
.map((s) => (s.length > 80 ? s.slice(0, 80) : s));
const unique = Array.from(new Set(normalized));
if (unique.length > 0) return unique;
const meaningfulLines = lines.filter((l) => !isHeaderLikeLine(l) && l.length >= 8);
const baseLines = meaningfulLines.length > 0 ? meaningfulLines : lines.filter((l) => l.length >= 8);
const fallbackLines = [
baseLines[0],
baseLines[baseLines.length - 1],
baseLines.find((l) => l.length >= 20),
].filter(Boolean) as string[];
const fallbackTokens: string[] = [];
for (const l of fallbackLines) {
const s1 = normalizeSpaces(l).slice(0, 120);
const s2 = normalizeSpaces(l).slice(-120);
if (s1.length >= 8) fallbackTokens.push(s1);
if (s2.length >= 8 && s2 !== s1) fallbackTokens.push(s2);
}
return Array.from(new Set(fallbackTokens));
};
let uiLogText = "";
let candidateKeywords: string[] = [];
const maxUiRetry = 6;
for (let i = 0; i < maxUiRetry; i++) {
uiLogText = await getUiLogText(page);
if (normalizeSpaces(uiLogText).length < 20) {
await new Promise((r) => setTimeout(r, 1500));
continue;
}
const uiLines = (uiLogText ?? "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const meaningfulLines = uiLines.filter((l) => !isHeaderLikeLine(l) && l.length >= 8);
if (meaningfulLines.length === 0) {
await new Promise((r) => setTimeout(r, 1500));
continue;
}
candidateKeywords = buildCandidateKeywords(uiLogText);
if (candidateKeywords.length > 0) break;
await new Promise((r) => setTimeout(r, 1500));
}
expect(normalizeSpaces(uiLogText).length, "UI 日志为空或仍在显示暂无日志").toBeGreaterThanOrEqual(20);
expect(candidateKeywords.length, "UI 日志中未提取到可用于匹配的关键片段").toBeGreaterThanOrEqual(1);
const tryCommands: string[] = [];
if (params.kubectlContainerName) {
tryCommands.push(
`kubectl logs -n ${namespace} ${podName} -c ${params.kubectlContainerName} --tail=500`,
);
}
tryCommands.push(`kubectl logs -n ${namespace} ${podName} --tail=500`);
tryCommands.push(`kubectl logs -n ${namespace} ${podName} --all-containers --tail=500`);
let kubectlLogs = "";
const maxRetry = 8;
const intervalMs = 2000;
for (let i = 0; i < maxRetry; i++) {
for (const cmd of tryCommands) {
const { stdout, code } = await execKubectl(ssh, cmd);
if (code === 0 && stdout.trim().length > 0) {
kubectlLogs = stdout;
break;
}
}
if (kubectlLogs.trim().length > 0) break;
await new Promise((r) => setTimeout(r, intervalMs));
}
expect(kubectlLogs.trim().length, "kubectl 获取日志失败或日志为空").toBeGreaterThanOrEqual(20);
const kubectlLogsNormalized = normalizeSpaces(kubectlLogs);
const containsAny = candidateKeywords.some((kw) => kubectlLogsNormalized.includes(kw));
expect(
containsAny,
`UI日志片段无法在 kubectl 日志中找到。\n` +
`候选匹配关键字=${candidateKeywords.join(" | ")}\n` +
`UI日志(前400)=${normalizeSpaces(uiLogText).slice(0, 400)}\n` +
`kubectl日志(前400)=${kubectlLogsNormalized.slice(0, 400)}`,
).toBeTruthy();
}
export{
goToAppMarket,
installComponent,
checkComponent,
changeString,
changePullPolicyLineWithWait,
uninstallComponent,
uninstallHelmComponent,
selectLog,
upgradeComponent,
revertComponent,
checkPrefixResourceNum,
gotoApplicationResourceTab,
clickSortHeader,
getColumnTexts,
expectSortedAsc,
expectSortedDesc,
expectDefaultSortState,
verifyResourceSortInResourceTab,
verifyOverviewSort,
withKubectlSsh,
kubectlGetFirstPodByNamePrefix,
verifyUiLogWithKubectl,
verifyLogExport,
openYamlQuickDeploy,
inputYamlContent,
deleteDeploymentByName,
getHelloWorldPackagePath,
prepareHelloWorldPackageLikeMarketplace,
};