Files
iot-dc3/dc3-web/scripts/testing/e2e-server.mjs
pnokerandClaude Code 3fb43c4239 feat(web): unify three-terminal control density and close audit defects
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>
2026-09-07 19:33:17 +08:00

250 lines
7.1 KiB
JavaScript

#!/usr/bin/env node
/*
* 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 {createReadStream, existsSync, readFileSync, statSync} from 'node:fs';
import {createServer, request as httpRequest} from 'node:http';
import {request as httpsRequest} from 'node:https';
import {extname, join, normalize, resolve, sep} from 'node:path';
import {pipeline} from 'node:stream';
import {fileURLToPath} from 'node:url';
const projectRoot = resolve(fileURLToPath(new URL('../../', import.meta.url)));
const distDir = resolve(projectRoot, 'dist');
const envDir = resolve(projectRoot, 'src/config/env');
const env = {
...readDotEnv(resolve(envDir, '.env')),
...readDotEnv(resolve(envDir, '.env.dev')),
...process.env,
};
const defaultBaseUrl = 'http://127.0.0.1:4174';
const baseUrl = new URL(env.E2E_BASE_URL || defaultBaseUrl);
const host = env.E2E_HOST || '0.0.0.0';
const portValue = env.E2E_PORT || baseUrl.port;
const port = Number(portValue);
// Fail fast on a missing/invalid port: Number('') === 0 and listen(0) would
// silently bind a random port that Playwright's webServer poll never finds.
if (!portValue || !Number.isInteger(port) || port <= 0) {
console.error(
`Invalid E2E server port "${portValue}". Set E2E_PORT or use an E2E_BASE_URL with an explicit port.`
);
process.exit(1);
}
const apiPrefix = normalizeApiPrefix(env.APP_API_PREFIX || '/api');
const apiTarget = new URL(
env.E2E_API_TARGET || `${env.APP_API_PATH || 'http://localhost'}:${env.APP_API_PORT || '8000'}`
);
if (!existsSync(resolve(distDir, 'index.html'))) {
console.error(`dist/index.html was not found. Run "pnpm build" before starting the E2E server.`);
process.exit(1);
}
const server = createServer((req, res) => {
if (!req.url) {
res.writeHead(400).end('Bad Request');
return;
}
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (isApiRequest(url.pathname)) {
proxyRequest(req, res);
return;
}
serveStatic(req, res, url.pathname);
});
server.on('clientError', (_error, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.listen(port, host, () => {
console.log(`E2E server listening on http://${host}:${port}`);
console.log(`Serving ${distDir}`);
console.log(`Proxying ${apiPrefix} -> ${apiTarget.origin}`);
});
function readDotEnv(file) {
if (!existsSync(file)) return {};
return Object.fromEntries(
readFileSync(file, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
const index = line.indexOf('=');
if (index < 0) return undefined;
const key = line.slice(0, index).trim();
let value = line.slice(index + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return [key, value];
})
.filter(Boolean)
);
}
function normalizeApiPrefix(prefix) {
const normalized = prefix.startsWith('/') ? prefix : `/${prefix}`;
return normalized.endsWith('/') && normalized.length > 1 ? normalized.slice(0, -1) : normalized;
}
function isApiRequest(pathname) {
return pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`);
}
function proxyRequest(req, res) {
const target = new URL(req.url, apiTarget);
const transport = target.protocol === 'https:' ? httpsRequest : httpRequest;
const headers = {
...req.headers,
host: target.host,
'x-forwarded-host': req.headers.host || '',
'x-forwarded-proto': 'http',
};
const proxy = transport(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
method: req.method,
headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
pipeline(proxyRes, res, (error) => {
if (error && !res.destroyed) {
res.destroy(error);
}
});
}
);
proxy.on('error', (error) => {
if (res.headersSent) {
res.destroy(error);
return;
}
res.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
res.end(
JSON.stringify({
ok: false,
code: 502,
message: `E2E proxy failed to reach ${apiTarget.origin}`,
})
);
});
pipeline(req, proxy, (error) => {
if (error) {
proxy.destroy(error);
}
});
}
function serveStatic(req, res, pathname) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, {allow: 'GET, HEAD'}).end('Method Not Allowed');
return;
}
const file = resolveStaticPath(pathname);
if (!file) {
res.writeHead(403).end('Forbidden');
return;
}
const {size} = statSync(file);
res.writeHead(200, {
'content-length': size,
'content-type': contentType(file),
'content-security-policy': "frame-ancestors 'none'",
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
});
if (req.method === 'HEAD') {
res.end();
return;
}
pipeline(createReadStream(file), res, (error) => {
if (error && !res.destroyed) {
res.destroy(error);
}
});
}
function resolveStaticPath(pathname) {
let decodedPath;
try {
decodedPath = decodeURIComponent(pathname);
} catch {
decodedPath = '/';
}
const normalized = normalize(decodedPath).replace(/^(\.\.[/\\])+/, '');
const relative = normalized === sep ? 'index.html' : normalized.replace(/^[/\\]+/, '');
let file = resolve(distDir, relative);
if (!isInsideDist(file)) return undefined;
if (existsSync(file) && statSync(file).isDirectory()) {
file = join(file, 'index.html');
}
if (existsSync(file) && statSync(file).isFile()) {
return file;
}
return resolve(distDir, 'index.html');
}
function isInsideDist(file) {
return file === distDir || file.startsWith(`${distDir}${sep}`);
}
function contentType(file) {
const types = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
return types[extname(file).toLowerCase()] || 'application/octet-stream';
}