mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-11-02 19:21:51 +08:00
Land the v7 three-terminal UX decision: drop the width/pointer-based 44px touch enlargement so one 32px density holds across desktop, tablet, and mobile, and rebuild entity pages on a responsive record list (ResponsiveRecordList + EntityMobileCard + per-entity mobile summaries). Tighten the nginx security headers (CSP frame-ancestors 'none', X-Frame-Options DENY) to match the static e2e server. Complete the dc3-web audit remediation, including the remaining LOW items from docs/dc3-web-low-defects-handoff.md: streaming-safe message loads in the agentic store, mock menu reparent cycle rejection, paged-list page clamping, fetch/axios mock request normalization, per-table attribute catalogs, post-flush alarm watchers, rowKey-agnostic delete state, localized card text fallbacks, and e2e-server port fail-fast. Gate: vue-tsc, eslint, vitest 687 passed, responsive e2e 64 passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
517 lines
18 KiB
JavaScript
517 lines
18 KiB
JavaScript
/*
|
|
* Copyright 2016-present the IoT DC3 original author or authors.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {chromium} from 'playwright';
|
|
import {
|
|
baseProtectedRoutes,
|
|
buildDestructiveDeleteCases,
|
|
buildEntityRoutes,
|
|
interactionPages,
|
|
protectedRouteProbes,
|
|
} from './data.mjs';
|
|
import {ensureE2eData} from './fixtures.mjs';
|
|
import {
|
|
apiPost,
|
|
assertClean,
|
|
clickButtonIfPresent,
|
|
closeModal,
|
|
createWatch,
|
|
delay,
|
|
hasEmptySearchEnum,
|
|
isBusinessApi,
|
|
listCount,
|
|
login,
|
|
markWatch,
|
|
shortText,
|
|
waitPage,
|
|
} from './support.mjs';
|
|
|
|
const BASE = process.env.E2E_BASE_URL || 'http://localhost:8080';
|
|
const CHROME = process.env.E2E_CHROME_PATH || '';
|
|
const HEADLESS = process.env.E2E_HEADLESS !== 'false';
|
|
|
|
const routeIds = {};
|
|
|
|
async function testSearch(page, watch, pageDef, result) {
|
|
const mark = markWatch(watch);
|
|
if (pageDef.placeholder) {
|
|
const input = page.getByPlaceholder(pageDef.placeholder).first();
|
|
if (await input.count()) {
|
|
await input.fill(pageDef.value || '');
|
|
result.actions.push(`fill ${pageDef.placeholder}`);
|
|
}
|
|
}
|
|
await clickButtonIfPresent(page, 'Search');
|
|
result.actions.push('search');
|
|
await assertClean(`${pageDef.name} search`, watch, mark);
|
|
|
|
const resetMark = markWatch(watch);
|
|
await clickButtonIfPresent(page, 'Reset');
|
|
result.actions.push('reset');
|
|
await assertClean(`${pageDef.name} reset`, watch, resetMark);
|
|
}
|
|
|
|
async function testFooterButtons(page, watch, pageDef, result) {
|
|
const footerButtons = page.locator('.tool-card-footer-page button.el-button.is-circle:visible');
|
|
const count = await footerButtons.count();
|
|
for (let i = 0; i < Math.min(count, 2); i += 1) {
|
|
const mark = markWatch(watch);
|
|
await footerButtons.nth(i).click();
|
|
await waitPage(page);
|
|
result.actions.push(i === 0 ? 'refresh' : 'sort');
|
|
await assertClean(`${pageDef.name} footer circle ${i}`, watch, mark);
|
|
}
|
|
}
|
|
|
|
async function testPagination(page, watch, pageDef, result) {
|
|
const next = page.locator('.el-pagination button.btn-next:visible').first();
|
|
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
|
|
const mark = markWatch(watch);
|
|
await next.click();
|
|
await waitPage(page);
|
|
result.actions.push('next page');
|
|
await assertClean(`${pageDef.name} next page`, watch, mark);
|
|
}
|
|
|
|
const prev = page.locator('.el-pagination button.btn-prev:visible').first();
|
|
if ((await prev.count()) && (await prev.isEnabled().catch(() => false))) {
|
|
const mark = markWatch(watch);
|
|
await prev.click();
|
|
await waitPage(page);
|
|
result.actions.push('previous page');
|
|
await assertClean(`${pageDef.name} previous page`, watch, mark);
|
|
}
|
|
|
|
const sizeSelect = page.locator('.el-pagination .el-select:visible').first();
|
|
if (await sizeSelect.count()) {
|
|
const mark = markWatch(watch);
|
|
await sizeSelect.click();
|
|
await delay(300);
|
|
const option = page.locator('.el-select-dropdown:visible .el-select-dropdown__item', {hasText: '24/page'}).last();
|
|
if (await option.count()) {
|
|
await option.click();
|
|
await waitPage(page);
|
|
result.actions.push('page size 24');
|
|
await assertClean(`${pageDef.name} page size`, watch, mark);
|
|
} else {
|
|
await page.keyboard.press('Escape').catch(() => {
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function testAddImportForms(page, watch, pageDef, result) {
|
|
if (pageDef.addDisabled) {
|
|
const add = page.getByRole('button', {name: 'Add'}).first();
|
|
if ((await add.count()) && !(await add.isEnabled().catch(() => true))) {
|
|
result.actions.push('disabled add checked');
|
|
}
|
|
}
|
|
|
|
if (pageDef.add) {
|
|
const mark = markWatch(watch);
|
|
const clicked = await clickButtonIfPresent(page, 'Add');
|
|
if (clicked) {
|
|
result.actions.push('open add form');
|
|
const dialog = page.locator('.el-dialog:visible').last();
|
|
if (!(await dialog.count())) throw new Error(`${pageDef.name} add did not open dialog`);
|
|
const confirm = dialog.getByRole('button', {name: 'Confirm'}).last();
|
|
if (await confirm.count()) {
|
|
await confirm.click();
|
|
await delay(400);
|
|
result.actions.push('add form validation');
|
|
}
|
|
const reset = dialog.getByRole('button', {name: 'Reset'}).last();
|
|
if (await reset.count()) {
|
|
await reset.click();
|
|
await delay(200);
|
|
result.actions.push('add form reset');
|
|
}
|
|
await closeModal(page);
|
|
await assertClean(`${pageDef.name} add form`, watch, mark);
|
|
}
|
|
}
|
|
|
|
if (pageDef.importButton) {
|
|
const mark = markWatch(watch);
|
|
const clicked = await clickButtonIfPresent(page, 'Import');
|
|
if (clicked) {
|
|
result.actions.push('open import form');
|
|
const dialog = page.locator('.el-dialog:visible').last();
|
|
if (!(await dialog.count())) throw new Error(`${pageDef.name} import did not open dialog`);
|
|
await closeModal(page);
|
|
await assertClean(`${pageDef.name} import form`, watch, mark);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function testRowActions(page, watch, pageDef, result) {
|
|
const actions = [
|
|
['Detail', pageDef.detail],
|
|
['Edit', pageDef.edit],
|
|
[pageDef.assign, Boolean(pageDef.assign)],
|
|
].filter(([name, enabled]) => name && enabled);
|
|
|
|
for (const [name] of actions) {
|
|
await page.goto(`${BASE}/#${pageDef.route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const mark = markWatch(watch);
|
|
const clicked = await clickButtonIfPresent(page, name);
|
|
if (!clicked) continue;
|
|
result.actions.push(String(name).toLowerCase());
|
|
await waitPage(page);
|
|
await closeModal(page);
|
|
await assertClean(`${pageDef.name} row ${name}`, watch, mark);
|
|
}
|
|
|
|
if (pageDef.deleteClick) {
|
|
await page.goto(`${BASE}/#${pageDef.route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const mark = markWatch(watch);
|
|
const deleted = await clickButtonIfPresent(page, 'Delete');
|
|
if (deleted) {
|
|
result.actions.push('delete popconfirm opened');
|
|
await closeModal(page);
|
|
await assertClean(`${pageDef.name} delete click`, watch, mark);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function testOverviewButtons(page, watch, result) {
|
|
for (const groupIndex of [0, 1]) {
|
|
for (const name of ['All', 'Unconfirmed', 'Today', 'Last 7 days', 'Last 30 days']) {
|
|
await page.goto(`${BASE}/#/settings/alarm/overview`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const group = page.locator('.event-overview__quick-actions').nth(groupIndex);
|
|
const button = group.getByRole('button', {name}).first();
|
|
if (!(await button.count())) continue;
|
|
const mark = markWatch(watch);
|
|
await button.click();
|
|
await waitPage(page);
|
|
result.actions.push(`overview ${groupIndex === 0 ? 'device' : 'driver'} ${name}`);
|
|
await assertClean(`Event overview ${name}`, watch, mark);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function testDashboardTabsAndButtons(page, watch) {
|
|
const result = {name: 'Dashboard widgets', ok: true, actions: [], error: ''};
|
|
try {
|
|
await page.goto(`${BASE}/#/home`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
|
|
const statRefreshButtons = page.locator('.home__stats .stat-card__refresh:visible');
|
|
const statRefreshCount = await statRefreshButtons.count();
|
|
for (let i = 0; i < statRefreshCount; i += 1) {
|
|
const mark = markWatch(watch);
|
|
await statRefreshButtons.nth(i).click();
|
|
await waitPage(page);
|
|
result.actions.push(`home stat refresh ${i + 1}`);
|
|
await assertClean(`Home stat refresh ${i + 1}`, watch, mark);
|
|
}
|
|
|
|
const analyticsGroups = page.locator('.analytics-tabs:visible');
|
|
const analyticsGroupCount = await analyticsGroups.count();
|
|
for (let groupIndex = 0; groupIndex < analyticsGroupCount; groupIndex += 1) {
|
|
const group = analyticsGroups.nth(groupIndex);
|
|
const tabs = group.locator('.el-tabs__item:visible');
|
|
const tabCount = await tabs.count();
|
|
for (let tabIndex = 0; tabIndex < tabCount; tabIndex += 1) {
|
|
const tab = tabs.nth(tabIndex);
|
|
const label = shortText(await tab.innerText(), 80) || `${groupIndex + 1}.${tabIndex + 1}`;
|
|
const mark = markWatch(watch);
|
|
await tab.click();
|
|
await waitPage(page);
|
|
result.actions.push(`home analytics tab ${label}`);
|
|
await assertClean(`Home analytics tab ${label}`, watch, mark);
|
|
}
|
|
|
|
const rangeButtons = group.locator('.el-segmented__item:visible');
|
|
const rangeCount = await rangeButtons.count();
|
|
for (let rangeIndex = 0; rangeIndex < rangeCount; rangeIndex += 1) {
|
|
const button = rangeButtons.nth(rangeIndex);
|
|
const label = shortText(await button.innerText(), 80) || `${groupIndex + 1}.${rangeIndex + 1}`;
|
|
const mark = markWatch(watch);
|
|
await button.click();
|
|
await waitPage(page);
|
|
result.actions.push(`home analytics range ${label}`);
|
|
await assertClean(`Home analytics range ${label}`, watch, mark);
|
|
}
|
|
}
|
|
|
|
const dashboardRefreshButtons = page.locator('.home .dashboard-card__tools button.el-button:visible');
|
|
const dashboardRefreshCount = await dashboardRefreshButtons.count();
|
|
for (let i = 0; i < Math.min(dashboardRefreshCount, 8); i += 1) {
|
|
const mark = markWatch(watch);
|
|
await dashboardRefreshButtons.nth(i).click();
|
|
await waitPage(page);
|
|
result.actions.push(`home dashboard refresh ${i + 1}`);
|
|
await assertClean(`Home dashboard refresh ${i + 1}`, watch, mark);
|
|
}
|
|
|
|
for (let i = 0; i < 6; i += 1) {
|
|
await page.goto(`${BASE}/#/home`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const card = page.locator('.home__stats .stat-card:visible').nth(i);
|
|
if (!(await card.count())) continue;
|
|
const mark = markWatch(watch);
|
|
await card.click();
|
|
await waitPage(page);
|
|
result.actions.push(`home stat card ${i + 1}`);
|
|
await assertClean(`Home stat card ${i + 1}`, watch, mark);
|
|
}
|
|
|
|
await page.goto(`${BASE}/#/settings/alarm/overview`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
for (const tab of ['Situation', 'Noise', 'Availability', 'SLA']) {
|
|
const locator = page.locator('.event-overview__tabs .el-tabs__item:visible', {hasText: tab}).first();
|
|
if (!(await locator.count())) continue;
|
|
const mark = markWatch(watch);
|
|
await locator.click();
|
|
await waitPage(page);
|
|
result.actions.push(`event tab ${tab}`);
|
|
await assertClean(`Event overview tab ${tab}`, watch, mark);
|
|
}
|
|
|
|
const eventRefreshButtons = page.locator(
|
|
'.event-overview .stat-card__refresh:visible, .event-overview .dashboard-card__tools button.el-button:visible'
|
|
);
|
|
const eventRefreshCount = await eventRefreshButtons.count();
|
|
for (let i = 0; i < Math.min(eventRefreshCount, 8); i += 1) {
|
|
const mark = markWatch(watch);
|
|
await eventRefreshButtons.nth(i).click();
|
|
await waitPage(page);
|
|
result.actions.push(`event refresh ${i + 1}`);
|
|
await assertClean(`Event refresh ${i + 1}`, watch, mark);
|
|
}
|
|
} catch (err) {
|
|
result.ok = false;
|
|
result.error = err.message;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function testActualDelete(page, watch, testCase) {
|
|
const suffix = Date.now().toString(36).slice(-8);
|
|
const name = `codex_${testCase.name.toLowerCase().replace(/[^a-z]+/g, '_')}_${suffix}`;
|
|
const add = await apiPost(page, testCase.addUrl, testCase.seed(name, suffix));
|
|
if (add.status >= 300) throw new Error(`${testCase.name} seed failed: ${JSON.stringify(add.data)}`);
|
|
const before = await listCount(page, testCase.listUrl, testCase.nameField, name);
|
|
if (before < 1) throw new Error(`${testCase.name} seed not found after add`);
|
|
|
|
await page.goto(`${BASE}/#${testCase.route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const input = page.getByPlaceholder(testCase.placeholder).first();
|
|
await input.fill(name);
|
|
const searchMark = markWatch(watch);
|
|
await clickButtonIfPresent(page, 'Search');
|
|
await assertClean(`${testCase.name} search before delete`, watch, searchMark);
|
|
await page.getByText(name).first().waitFor({state: 'visible', timeout: 10000});
|
|
|
|
const deleteMark = markWatch(watch);
|
|
await page.getByRole('button', {name: 'Delete', exact: true}).first().click();
|
|
// Element Plus teleports the popover outside the table and may briefly
|
|
// render the wrapper before its inner `.el-popconfirm` gets the visible
|
|
// state. Target the final visible action button instead of depending on a
|
|
// particular teleport wrapper/class hierarchy.
|
|
const confirmButton = page.locator(
|
|
'.el-popover.el-popper:visible .el-popconfirm__action button.el-button--primary'
|
|
).last();
|
|
await confirmButton.waitFor({state: 'visible', timeout: 5000});
|
|
await confirmButton.click();
|
|
await waitPage(page);
|
|
await assertClean(`${testCase.name} confirmed delete`, watch, deleteMark);
|
|
|
|
const after = await listCount(page, testCase.listUrl, testCase.nameField, name);
|
|
if (after !== 0) throw new Error(`${testCase.name} still exists after UI delete, count=${after}`);
|
|
return `${testCase.name}: ${name}`;
|
|
}
|
|
|
|
async function destructiveDeleteTest(page, watch) {
|
|
const result = {name: 'Actual delete buttons', ok: true, actions: [], error: ''};
|
|
try {
|
|
for (const testCase of buildDestructiveDeleteCases(routeIds)) {
|
|
const action = await testActualDelete(page, watch, testCase);
|
|
result.actions.push(action);
|
|
}
|
|
} catch (err) {
|
|
result.ok = false;
|
|
result.error = err.message;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function securityTest(browser) {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
const results = [];
|
|
const businessApiRequests = [];
|
|
page.on('request', (req) => {
|
|
if (isBusinessApi(req.url())) businessApiRequests.push(req.url());
|
|
});
|
|
|
|
for (const route of [...baseProtectedRoutes, ...protectedRouteProbes]) {
|
|
await page.goto(`${BASE}/#${route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
results.push({route, ok: page.url().includes('/login'), url: page.url()});
|
|
}
|
|
|
|
await context.close();
|
|
return {
|
|
total: results.length,
|
|
passed: results.filter((item) => item.ok).length,
|
|
failed: results.filter((item) => !item.ok),
|
|
businessApiRequests,
|
|
};
|
|
}
|
|
|
|
async function routeOpenTest(page, watch) {
|
|
const results = [];
|
|
const openRoutes = ['/login', '/403', '/404', '/500', ...baseProtectedRoutes, ...buildEntityRoutes(routeIds)];
|
|
for (const route of openRoutes) {
|
|
const mark = markWatch(watch);
|
|
await page.goto(`${BASE}/#${route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const body = shortText(
|
|
await page
|
|
.locator('body')
|
|
.innerText()
|
|
.catch(() => '')
|
|
);
|
|
let ok = true;
|
|
let reason = '';
|
|
try {
|
|
await assertClean(`route ${route}`, watch, mark);
|
|
} catch (err) {
|
|
ok = false;
|
|
reason = err.message;
|
|
}
|
|
if (!['/login', '/403', '/404', '/500'].includes(route) && page.url().includes('/login')) {
|
|
ok = false;
|
|
reason = 'unexpected redirect to login';
|
|
}
|
|
results.push({route, ok, url: page.url(), body, reason});
|
|
}
|
|
return {
|
|
total: results.length,
|
|
passed: results.filter((item) => item.ok).length,
|
|
failed: results.filter((item) => !item.ok),
|
|
};
|
|
}
|
|
|
|
async function interactionTest(page, watch) {
|
|
const results = [];
|
|
for (const pageDef of interactionPages) {
|
|
const result = {name: pageDef.name, route: pageDef.route, ok: true, actions: [], error: ''};
|
|
try {
|
|
await page.goto(`${BASE}/#${pageDef.route}`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
await testSearch(page, watch, pageDef, result);
|
|
await testFooterButtons(page, watch, pageDef, result);
|
|
await testPagination(page, watch, pageDef, result);
|
|
await testAddImportForms(page, watch, pageDef, result);
|
|
await testRowActions(page, watch, pageDef, result);
|
|
} catch (err) {
|
|
result.ok = false;
|
|
result.error = err.message;
|
|
}
|
|
results.push(result);
|
|
}
|
|
|
|
const overview = {
|
|
name: 'Settings Alarm Overview',
|
|
route: '/settings/alarm/overview',
|
|
ok: true,
|
|
actions: [],
|
|
error: '',
|
|
};
|
|
try {
|
|
await testOverviewButtons(page, watch, overview);
|
|
} catch (err) {
|
|
overview.ok = false;
|
|
overview.error = err.message;
|
|
}
|
|
results.push(overview);
|
|
results.push(await testDashboardTabsAndButtons(page, watch));
|
|
results.push(await destructiveDeleteTest(page, watch));
|
|
|
|
return {
|
|
total: results.length,
|
|
passed: results.filter((item) => item.ok).length,
|
|
failed: results.filter((item) => !item.ok),
|
|
results,
|
|
};
|
|
}
|
|
|
|
const launchOptions = {
|
|
headless: HEADLESS,
|
|
};
|
|
|
|
if (CHROME) {
|
|
launchOptions.executablePath = CHROME;
|
|
}
|
|
|
|
const browser = await chromium.launch(launchOptions);
|
|
|
|
const security = await securityTest(browser);
|
|
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
const watch = createWatch(page);
|
|
await login(page, BASE);
|
|
|
|
const cleanupE2eData = await ensureE2eData(page, routeIds);
|
|
let routeOpen;
|
|
let interactions;
|
|
|
|
try {
|
|
routeOpen = await routeOpenTest(page, watch);
|
|
interactions = await interactionTest(page, watch);
|
|
} finally {
|
|
await cleanupE2eData();
|
|
await context.close();
|
|
await browser.close();
|
|
}
|
|
|
|
const output = {
|
|
security,
|
|
routeOpen,
|
|
interactions,
|
|
totals: {
|
|
pageErrors: watch.pageErrors.length,
|
|
consoleErrors: watch.consoleErrors.length,
|
|
badResponses: watch.badResponses.length,
|
|
businessRequestsWithBodies: watch.requestBodies.length,
|
|
emptySearchEnumBodies: hasEmptySearchEnum(watch.requestBodies),
|
|
},
|
|
};
|
|
|
|
console.log(JSON.stringify(output, null, 2));
|
|
|
|
if (
|
|
security.failed.length ||
|
|
security.businessApiRequests.length ||
|
|
routeOpen.failed.length ||
|
|
interactions.failed.length ||
|
|
output.totals.pageErrors ||
|
|
output.totals.consoleErrors ||
|
|
output.totals.badResponses ||
|
|
output.totals.emptySearchEnumBodies.length
|
|
) {
|
|
process.exit(1);
|
|
}
|