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>
381 lines
12 KiB
JavaScript
381 lines
12 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/>.
|
|
*/
|
|
|
|
const E2E_CREDENTIALS = {
|
|
tenant: process.env.E2E_TENANT || 'default',
|
|
name: process.env.E2E_USERNAME || 'dc3',
|
|
password: process.env.E2E_PASSWORD || 'dc3dc3dc3',
|
|
};
|
|
|
|
export function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
export function isBusinessApi(url) {
|
|
return url.includes('/api/v3/');
|
|
}
|
|
|
|
export function shortText(text, size = 260) {
|
|
return String(text || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, size);
|
|
}
|
|
|
|
export async function waitPage(page) {
|
|
await page.waitForLoadState('domcontentloaded').catch(() => {
|
|
});
|
|
await page.waitForLoadState('networkidle', {timeout: 6000}).catch(() => {
|
|
});
|
|
await delay(500);
|
|
}
|
|
|
|
export async function login(page, base) {
|
|
await page.goto(`${base}/#/login`, {waitUntil: 'domcontentloaded'});
|
|
await waitPage(page);
|
|
const loginButton = page.getByRole('button', {name: 'Login'});
|
|
if (await loginButton.count()) {
|
|
await page.getByPlaceholder('Please enter tenant name').fill(E2E_CREDENTIALS.tenant);
|
|
await page.getByPlaceholder('Please enter username').fill(E2E_CREDENTIALS.name);
|
|
await page.locator('.login-form input[type="password"]').fill(E2E_CREDENTIALS.password);
|
|
await loginButton.click();
|
|
await page.waitForURL((url) => !url.hash.includes('/login'), {timeout: 15000}).catch(() => {
|
|
});
|
|
}
|
|
await waitPage(page);
|
|
if (page.url().includes('/login')) {
|
|
throw new Error('Login failed, still on login page');
|
|
}
|
|
}
|
|
|
|
export function createWatch(page) {
|
|
const state = {
|
|
pageErrors: [],
|
|
consoleErrors: [],
|
|
badResponses: [],
|
|
requestBodies: [],
|
|
};
|
|
|
|
page.on('pageerror', (err) => state.pageErrors.push(err.message));
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') state.consoleErrors.push(msg.text());
|
|
});
|
|
page.on('request', (req) => {
|
|
if (!isBusinessApi(req.url())) return;
|
|
const body = req.postData();
|
|
if (!body) return;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(body);
|
|
} catch {
|
|
parsed = body;
|
|
}
|
|
state.requestBodies.push({method: req.method(), url: req.url(), body: parsed});
|
|
});
|
|
page.on('response', async (res) => {
|
|
if (!isBusinessApi(res.url()) || res.status() < 400) return;
|
|
let body;
|
|
try {
|
|
body = await res.text();
|
|
} catch {
|
|
body = '';
|
|
}
|
|
state.badResponses.push({status: res.status(), url: res.url(), body: shortText(body)});
|
|
});
|
|
return state;
|
|
}
|
|
|
|
export function markWatch(watch) {
|
|
return {
|
|
pageErrors: watch.pageErrors.length,
|
|
consoleErrors: watch.consoleErrors.length,
|
|
badResponses: watch.badResponses.length,
|
|
requestBodies: watch.requestBodies.length,
|
|
};
|
|
}
|
|
|
|
function snapshotWatch(watch, from) {
|
|
return {
|
|
pageErrors: watch.pageErrors.slice(from.pageErrors),
|
|
consoleErrors: watch.consoleErrors.slice(from.consoleErrors),
|
|
badResponses: watch.badResponses.slice(from.badResponses),
|
|
requestBodies: watch.requestBodies.slice(from.requestBodies),
|
|
};
|
|
}
|
|
|
|
export function hasEmptySearchEnum(requests) {
|
|
return requests
|
|
.filter((item) => item.method === 'POST')
|
|
.filter((item) =>
|
|
/\/(driver|profile|device|point|point_value|api|resource|menu|role|user|group|label|event|model\/config|provider)\/list/.test(
|
|
item.url
|
|
)
|
|
)
|
|
.filter((item) => item.body && typeof item.body === 'object')
|
|
.filter(
|
|
(item) =>
|
|
item.body.enableFlag === '' || item.body.rangeKey === '' || item.body.type === '' || item.body.scope === ''
|
|
);
|
|
}
|
|
|
|
export async function assertClean(step, watch, mark) {
|
|
const diff = snapshotWatch(watch, mark);
|
|
const emptyEnumBodies = hasEmptySearchEnum(diff.requestBodies);
|
|
if (diff.pageErrors.length || diff.consoleErrors.length || diff.badResponses.length || emptyEnumBodies.length) {
|
|
throw new Error(
|
|
`${step} failed: pageErrors=${JSON.stringify(diff.pageErrors)}, consoleErrors=${JSON.stringify(
|
|
diff.consoleErrors
|
|
)}, badResponses=${JSON.stringify(diff.badResponses)}, emptyEnumBodies=${JSON.stringify(emptyEnumBodies)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function closeModal(page) {
|
|
const modal = page
|
|
.locator('.el-dialog:visible, .el-drawer:visible, .el-popover:visible, .el-message-box:visible')
|
|
.last();
|
|
if (await modal.count()) {
|
|
const cancel = modal.getByRole('button', {name: /Cancel|Close|No|取消|关闭|否/}).last();
|
|
if (await cancel.count()) {
|
|
await cancel.click().catch(() => {
|
|
});
|
|
await delay(300);
|
|
return;
|
|
}
|
|
}
|
|
await page.keyboard.press('Escape').catch(() => {
|
|
});
|
|
await delay(300);
|
|
}
|
|
|
|
export async function clickButtonIfPresent(page, name, options = {}) {
|
|
const locator = page.getByRole('button', {name}).filter({hasNot: page.locator('.is-disabled')});
|
|
const count = await locator.count();
|
|
if (!count) return false;
|
|
const btn = locator.nth(options.index || 0);
|
|
if (!(await btn.isVisible().catch(() => false)) || !(await btn.isEnabled().catch(() => false))) return false;
|
|
await btn.click();
|
|
await waitPage(page);
|
|
return true;
|
|
}
|
|
|
|
export async function apiPost(page, url, body = {}, params = {}) {
|
|
const response = await page.evaluate(
|
|
async ({requestUrl, requestBody, requestParams}) => {
|
|
const decodeStorage = (key) => {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' && 'content' in parsed ? parsed.content : parsed;
|
|
} catch {
|
|
try {
|
|
return JSON.parse(atob(raw)).content;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
};
|
|
const target = new URL(requestUrl, window.location.origin);
|
|
Object.entries(requestParams).forEach(([key, value]) => {
|
|
if (value !== undefined) target.searchParams.set(key, String(value));
|
|
});
|
|
const targetUrl =
|
|
target.origin === window.location.origin ? `${target.pathname}${target.search}` : target.toString();
|
|
const headers = {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-Auth-Tenant': decodeStorage('X-Auth-Tenant'),
|
|
'X-Auth-Login': decodeStorage('X-Auth-Login'),
|
|
};
|
|
const res = await fetch(targetUrl, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(requestBody),
|
|
credentials: 'include',
|
|
});
|
|
const text = await res.text();
|
|
return {status: res.status, text};
|
|
},
|
|
{requestUrl: url, requestBody: body, requestParams: params}
|
|
);
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(response.text);
|
|
} catch {
|
|
data = response.text;
|
|
}
|
|
|
|
return {status: response.status, data, text: response.text};
|
|
}
|
|
|
|
export async function apiGet(page, url, params = {}) {
|
|
const response = await page.evaluate(
|
|
async ({requestUrl, requestParams}) => {
|
|
const decodeStorage = (key) => {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' && 'content' in parsed ? parsed.content : parsed;
|
|
} catch {
|
|
try {
|
|
return JSON.parse(atob(raw)).content;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
};
|
|
const target = new URL(requestUrl, window.location.origin);
|
|
Object.entries(requestParams).forEach(([key, value]) => {
|
|
if (value !== undefined) target.searchParams.set(key, String(value));
|
|
});
|
|
const targetUrl =
|
|
target.origin === window.location.origin ? `${target.pathname}${target.search}` : target.toString();
|
|
const res = await fetch(targetUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Auth-Tenant': decodeStorage('X-Auth-Tenant'),
|
|
'X-Auth-Login': decodeStorage('X-Auth-Login'),
|
|
},
|
|
credentials: 'include',
|
|
});
|
|
return {status: res.status, text: await res.text()};
|
|
},
|
|
{requestUrl: url, requestParams: params}
|
|
);
|
|
let data;
|
|
try {
|
|
data = JSON.parse(response.text);
|
|
} catch {
|
|
data = response.text;
|
|
}
|
|
return {status: response.status, data, text: response.text};
|
|
}
|
|
|
|
export async function apiDelete(page, url, params = {}) {
|
|
const response = await page.evaluate(
|
|
async ({requestUrl, requestParams}) => {
|
|
const decodeStorage = (key) => {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) return undefined;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' && 'content' in parsed ? parsed.content : parsed;
|
|
} catch {
|
|
try {
|
|
return JSON.parse(atob(raw)).content;
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
};
|
|
const target = new URL(requestUrl, window.location.origin);
|
|
Object.entries(requestParams).forEach(([key, value]) => {
|
|
if (value !== undefined) target.searchParams.set(key, String(value));
|
|
});
|
|
const targetUrl =
|
|
target.origin === window.location.origin ? `${target.pathname}${target.search}` : target.toString();
|
|
const res = await fetch(targetUrl, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Auth-Tenant': decodeStorage('X-Auth-Tenant'),
|
|
'X-Auth-Login': decodeStorage('X-Auth-Login'),
|
|
},
|
|
credentials: 'include',
|
|
});
|
|
return {status: res.status, text: await res.text()};
|
|
},
|
|
{requestUrl: url, requestParams: params}
|
|
);
|
|
let data;
|
|
try {
|
|
data = JSON.parse(response.text);
|
|
} catch {
|
|
data = response.text;
|
|
}
|
|
return {status: response.status, data, text: response.text};
|
|
}
|
|
|
|
export async function deleteVersionedEntity(page, baseUrl, id) {
|
|
const current = await apiGet(page, `${baseUrl}/get_by_id`, {id});
|
|
const version = Number(current.data?.version);
|
|
if (!Number.isInteger(version) || version < 0) {
|
|
throw new Error(`Cannot delete ${baseUrl}/${id}: current version is missing`);
|
|
}
|
|
return apiDelete(page, `${baseUrl}/delete`, {id, version});
|
|
}
|
|
|
|
export async function listCount(page, url, nameField, name) {
|
|
const res = await apiPost(page, url, {offset: 0, limit: 10, [nameField]: name});
|
|
if (res.status >= 300) {
|
|
throw new Error(`Failed to list ${url}: ${JSON.stringify(res.data)}`);
|
|
}
|
|
return res.data?.items?.length || 0;
|
|
}
|
|
|
|
export function idOf(record) {
|
|
if (!record || typeof record !== 'object') return undefined;
|
|
const id = record.id;
|
|
return id == null ? undefined : String(id);
|
|
}
|
|
|
|
export async function firstRecord(page, url) {
|
|
const res = await apiPost(page, url, {offset: 0, limit: 1});
|
|
return res.status < 300 ? res.data?.items?.[0] : undefined;
|
|
}
|
|
|
|
export async function listByName(page, url, nameField, name) {
|
|
const res = await apiPost(page, url, {offset: 0, limit: 1, [nameField]: name});
|
|
return res.status < 300 ? res.data?.items?.[0] : undefined;
|
|
}
|
|
|
|
export function uniqueName(prefix) {
|
|
return `e2e_${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
export async function createEntity(page, cleanupStack, seed) {
|
|
const name = seed.body[seed.nameField];
|
|
const existing = await listByName(page, seed.listUrl, seed.nameField, name);
|
|
if (idOf(existing)) return idOf(existing);
|
|
|
|
const add = await apiPost(page, seed.addUrl, seed.body);
|
|
if (add.status >= 300) {
|
|
throw new Error(`Failed to seed ${seed.addUrl}: ${JSON.stringify(add.data)}`);
|
|
}
|
|
const created = add.data || (await listByName(page, seed.listUrl, seed.nameField, name));
|
|
const id = idOf(created);
|
|
if (!id) {
|
|
throw new Error(`Seeded ${seed.addUrl} but could not resolve id`);
|
|
}
|
|
|
|
cleanupStack.push(async () => {
|
|
const baseUrl = seed.deleteUrl.replace(/\/delete$/, '');
|
|
if (seed.versioned) {
|
|
await deleteVersionedEntity(page, baseUrl, id).catch(() => {});
|
|
} else {
|
|
await apiDelete(page, seed.deleteUrl, {id}).catch(() => {});
|
|
}
|
|
});
|
|
return id;
|
|
}
|