390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
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;
|
|
}
|