fix web ui smoke and brand assets

This commit is contained in:
hectorzhao
2026-06-30 11:13:30 +08:00
parent 0dfb2988b2
commit 9920575bba
103 changed files with 6650 additions and 135 deletions
+389
View File
@@ -0,0 +1,389 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, request as playwrightRequest } from 'playwright';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const timeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_TIMEOUT_MS || 30000);
const stepTimeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_STEP_TIMEOUT_MS || 10000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const headless = process.env.LISGLOSIPS_WEB_UI_HEADLESS !== '0';
const browserChannel = process.env.LISGLOSIPS_WEB_UI_CHANNEL || '';
const coreMenus = [
'概览 Dashboard',
'客户管理',
'客户网关管理',
'业务前缀管理',
'充值记录',
'供应商管理',
'落地网关管理',
'落地线路组',
'号码库',
'当前通话',
'话单中心',
'质检中心',
'用户管理',
'角色与权限',
'操作日志',
];
const safeActionsByMenu = {
'概览 Dashboard': ['刷新'],
'客户管理': ['编辑'],
'客户网关管理': ['编辑'],
'业务前缀管理': ['编辑'],
'充值记录': ['刷新'],
'供应商管理': ['编辑'],
'落地网关管理': ['编辑'],
'落地线路组': ['编辑'],
'号码库': ['刷新'],
'当前通话': ['刷新通话'],
'话单中心': ['查看详情'],
'质检中心': ['查看详情', '刷新'],
'用户管理': ['编辑'],
'角色与权限': ['编辑'],
'操作日志': ['查看详情'],
};
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) {
return '';
}
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
function parseSetCookie(value) {
const firstCookie = Array.isArray(value) ? value[0] : String(value || '').split('\n')[0];
const [pair, ...attributes] = firstCookie.split(';').map((item) => item.trim());
const index = pair.indexOf('=');
if (index <= 0) {
return null;
}
const cookie = {
name: pair.slice(0, index),
value: pair.slice(index + 1),
url: baseUrl,
path: '/',
httpOnly: false,
secure: baseUrl.startsWith('https://'),
sameSite: 'Lax',
};
for (const attribute of attributes) {
const [rawName, rawValue] = attribute.split('=');
const name = rawName.toLowerCase();
if (name === 'path' && rawValue) cookie.path = rawValue;
if (name === 'httponly') cookie.httpOnly = true;
if (name === 'secure') cookie.secure = true;
if (name === 'samesite' && rawValue && ['Strict', 'Lax', 'None'].includes(rawValue)) cookie.sameSite = rawValue;
}
return cookie;
}
function reportLine(check) {
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
}
async function loginThroughUi(page) {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: timeoutMs });
const usernameInput = page.getByLabel('用户名');
const firstScreen = await Promise.race([
usernameInput.waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'login').catch(() => null),
page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'app').catch(() => null),
]);
if (firstScreen === 'app') {
return { skipped: true, captchaCodeLength: 0 };
}
if (firstScreen !== 'login') {
throw new Error('Neither login form nor authenticated app shell became visible.');
}
const captchaImage = page.locator('.captcha-image img');
await captchaImage.waitFor({ state: 'visible', timeout: timeoutMs });
const captchaCode = decodeCaptcha(await captchaImage.getAttribute('src'));
await usernameInput.fill(username);
await page.getByLabel('密码').fill(password);
await page.getByLabel('图形验证码').fill(captchaCode);
await page.getByRole('button', { name: '登录' }).click();
await page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs });
return { skipped: false, captchaCodeLength: captchaCode.length };
}
async function loginByApi() {
const api = await playwrightRequest.newContext({
baseURL: baseUrl,
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
extraHTTPHeaders: {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-web-ui-smoke/1.0',
},
});
try {
const captchaResponse = await api.get('/api/v2/auth/captcha', { timeout: timeoutMs });
const captcha = await captchaResponse.json();
const captchaCode = decodeCaptcha(captcha.imageDataUrl);
const loginResponse = await api.post('/api/v2/auth/login', {
timeout: timeoutMs,
data: {
username,
password,
captchaId: captcha.captchaId,
captchaCode,
},
});
const loginBody = await loginResponse.json().catch(() => null);
return {
ok: loginResponse.ok(),
status: loginResponse.status(),
accessToken: loginBody?.accessToken,
user: loginBody?.user,
refreshCookie: parseSetCookie(loginResponse.headers()['set-cookie']),
captchaCodeLength: captchaCode.length,
};
} finally {
await api.dispose();
}
}
async function checkMenu(page, label) {
const beforeErrors = errorEvents.length;
const button = page.locator('button').filter({ hasText: label }).first();
const found = await button.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!found) {
return {
name: `${label} menu is visible`,
pass: false,
detail: 'menu button not found or not visible',
};
}
await button.click();
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
await page.getByText(label, { exact: true }).first().waitFor({ state: 'visible', timeout: stepTimeoutMs }).catch(() => {});
await page.waitForTimeout(300);
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
const afterErrors = errorEvents.slice(beforeErrors);
const visibleTextLength = bodyText.replace(/\s+/g, '').length;
const hasApiUnavailable = bodyText.includes('API 数据不可用');
const isLoginScreen = bodyText.includes('请输入用户名') && bodyText.includes('图形验证码');
return {
name: `${label} renders without browser errors`,
pass: afterErrors.length === 0 && visibleTextLength > 80 && !hasApiUnavailable && !isLoginScreen,
detail: [
`textLength=${visibleTextLength}`,
hasApiUnavailable ? 'api-unavailable' : 'api-ok',
isLoginScreen ? 'login-screen' : 'authenticated',
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
].join(', '),
};
}
async function closeOverlay(page) {
const closeButtons = [
page.getByRole('button', { name: '取消' }),
page.getByRole('button', { name: '关闭' }),
page.getByRole('button', { name: '收起' }),
];
for (const closeButton of closeButtons) {
const count = await closeButton.count().catch(() => 0);
if (count > 0 && await closeButton.first().isVisible().catch(() => false)) {
await closeButton.first().click().catch(() => {});
await page.waitForTimeout(200);
return;
}
}
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(200);
}
async function verifyCustomerGatewayPrefixToggle(page) {
const modalVisible = await page.getByRole('dialog', { name: '编辑客户网关' }).isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!modalVisible) {
return 'customer-gateway-edit-modal-not-visible';
}
const calleeMode = page.locator('select').filter({ has: page.locator('option[value="BUSINESS_PREFIXES"]') }).first();
const canSelectMode = await calleeMode.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!canSelectMode) {
return 'business-prefix-mode-select-not-visible';
}
await calleeMode.selectOption('BUSINESS_PREFIXES');
await page.waitForTimeout(200);
const firstPrefixInput = page.locator('.checkbox-grid input[type="checkbox"]').first();
const hasPrefix = await firstPrefixInput.count().catch(() => 0);
if (!hasPrefix) {
return 'no-business-prefix-option; skipped';
}
const firstPrefixLabel = page.locator('.checkbox-grid .ui-check').first();
const before = await firstPrefixInput.isChecked();
await firstPrefixLabel.click();
await page.waitForTimeout(100);
const afterFirstClick = await firstPrefixInput.isChecked();
await firstPrefixLabel.click();
await page.waitForTimeout(100);
const afterSecondClick = await firstPrefixInput.isChecked();
if (afterFirstClick === before || afterSecondClick !== before) {
throw new Error(`customer gateway prefix checkbox did not toggle correctly: before=${before}, afterFirst=${afterFirstClick}, afterSecond=${afterSecondClick}`);
}
return `business-prefix-checkbox-toggle-ok: ${before}->${afterFirstClick}->${afterSecondClick}`;
}
async function checkSafeAction(page, label) {
const actions = safeActionsByMenu[label] || [];
if (!actions.length) {
return {
name: `${label} safe action is configured`,
pass: true,
detail: 'no action configured',
};
}
for (const action of actions) {
const locator = page.getByRole('button', { name: action }).first();
const count = await locator.count().catch(() => 0);
const visible = count > 0 && await locator.isVisible().catch(() => false);
const enabled = visible && await locator.isEnabled().catch(() => false);
if (!enabled) {
continue;
}
const beforeErrors = errorEvents.length;
await locator.click();
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
await page.waitForTimeout(500);
const actionDetails = [];
if (label === '客户网关管理' && action === '编辑') {
actionDetails.push(await verifyCustomerGatewayPrefixToggle(page));
}
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
const afterErrors = errorEvents.slice(beforeErrors);
await closeOverlay(page);
return {
name: `${label} action ${action} works without browser errors`,
pass: afterErrors.length === 0 && !bodyText.includes('API 数据不可用'),
detail: [
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
...actionDetails,
].join(', '),
};
}
return {
name: `${label} safe action is available`,
pass: true,
detail: `no visible enabled action among ${actions.join('/')}; skipped`,
};
}
const checks = [];
const errorEvents = [];
const login = await loginByApi();
checks.push({
name: 'API login succeeds for browser smoke',
pass: login.ok && typeof login.accessToken === 'string' && Boolean(login.refreshCookie),
detail: `status=${login.status}, captchaLength=${login.captchaCodeLength}, permissionCount=${Array.isArray(login.user?.permissions) ? login.user.permissions.length : 0}`,
});
let browser;
let context;
if (checks[0].pass) {
browser = await chromium.launch({ headless, ...(browserChannel ? { channel: browserChannel } : {}) });
context = await browser.newContext({
baseURL: baseUrl,
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.on('console', (message) => {
if (message.type() === 'error') {
errorEvents.push({ source: 'console', message: message.text() });
}
});
page.on('pageerror', (error) => {
errorEvents.push({ source: 'pageerror', message: error.message });
});
const uiLogin = await loginThroughUi(page);
checks.push({
name: 'UI login succeeds before menu smoke',
pass: uiLogin.skipped || uiLogin.captchaCodeLength >= 4,
detail: uiLogin.skipped ? 'already authenticated' : `captchaLength=${uiLogin.captchaCodeLength}`,
});
for (const label of coreMenus) {
console.log(`CHECK ${label}`);
const menuCheck = await checkMenu(page, label);
checks.push(menuCheck);
if (menuCheck.pass) {
console.log(`ACTION ${label}`);
checks.push(await checkSafeAction(page, label));
}
}
}
await context?.close();
await browser?.close();
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_WEB_UI_SMOKE_${stamp}.md`);
const failed = checks.filter((check) => !check.pass);
const report = [
'# Remote Web UI Smoke Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Headless: ${headless}`,
`Browser Channel: ${browserChannel || 'playwright-default'}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Guardrails',
'',
'- Fails on browser `pageerror` and console `error` events.',
'- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.',
'- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.',
'- Password and token values are intentionally omitted.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const check of checks) {
console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name} - ${check.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) {
process.exitCode = 1;
}