Files
LWLT-AI/archive/handoff/2026-07-12/legacy-erp-handoff/tools/erp_deployment_health_check.js
2026-07-13 19:57:46 +08:00

562 lines
17 KiB
JavaScript

#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const {
classifyErpSession,
installErpDialogHandler,
isLoginRequiredText,
} = require('./erp_session_guard');
const DEFAULT_CONFIG = {
erp: {
baseUrl: 'https://ltjt.yunzhi.run',
mainUrl: 'https://ltjt.yunzhi.run/System/Mainlt.asp',
ordersUrl: 'https://ltjt.yunzhi.run/System/Business/orders.asp',
},
browser: {
profileDir: 'runtime/erp-order-entry/browser-profile',
chromeExecutable: '',
chromeCandidates: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
],
launchArgs: [],
interactiveLoginWaitMs: 600000,
headless: false,
viewport: { width: 1440, height: 900 },
},
paths: {
workRoot: 'runtime/erp-order-entry',
downloadDir: 'runtime/erp-order-entry/downloads',
outputDir: 'runtime/erp-order-entry/outputs',
auditDir: 'runtime/erp-order-entry/audit',
logDir: 'runtime/erp-order-entry/logs',
healthDir: 'diagnostics/deployment-health',
},
pdf: {
converter: 'chromium-html',
convertConcurrency: 3,
verificationMode: 'sample',
},
delivery: {
returnIdentifiersFirst: true,
sendPdfFollowUp: true,
},
safety: {
allowRealSubmit: false,
requireExplicitSubmitFlag: true,
disableOldSkillBeforeEnable: true,
},
};
function isAbsolutePath(value) {
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
}
function resolveFromRoot(workspaceRoot, value) {
if (!value || typeof value !== 'string') return value;
if (isAbsolutePath(value)) return path.normalize(value);
return path.join(workspaceRoot, value);
}
function mergeConfig(base, override) {
const result = { ...base };
for (const [key, value] of Object.entries(override || {})) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
base[key] &&
typeof base[key] === 'object' &&
!Array.isArray(base[key])
) {
result[key] = mergeConfig(base[key], value);
} else {
result[key] = value;
}
}
return result;
}
function normalizeConfig(rawConfig, workspaceRoot) {
const config = mergeConfig(DEFAULT_CONFIG, rawConfig || {});
const normalized = JSON.parse(JSON.stringify(config));
normalized.browser.profileDir = resolveFromRoot(workspaceRoot, normalized.browser.profileDir);
normalized.browser.chromeExecutable = resolveFromRoot(workspaceRoot, normalized.browser.chromeExecutable);
normalized.browser.chromeCandidates = (normalized.browser.chromeCandidates || []).map((candidate) =>
resolveFromRoot(workspaceRoot, candidate)
);
for (const key of ['workRoot', 'downloadDir', 'outputDir', 'auditDir', 'logDir', 'healthDir']) {
normalized.paths[key] = resolveFromRoot(workspaceRoot, normalized.paths[key]);
}
return normalized;
}
function readJson(filePath) {
const text = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
return JSON.parse(text);
}
function loadConfig(configPath, workspaceRoot) {
const raw = readJson(configPath);
return normalizeConfig(raw, workspaceRoot);
}
function selectExistingPath(candidates) {
const checked = [];
for (const candidate of candidates || []) {
if (!candidate) continue;
checked.push(candidate);
if (fs.existsSync(candidate)) {
return { ok: true, path: candidate, checked };
}
}
return { ok: false, path: '', checked };
}
function ensureWritableDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
const proofFile = path.join(dirPath, `.health-write-${process.pid}-${Date.now()}.tmp`);
fs.writeFileSync(proofFile, 'ok', 'utf8');
return { ok: fs.existsSync(proofFile), path: dirPath, proofFile };
}
function summarizeChecks(checks) {
const failed = checks.filter((check) => !check.ok);
const failedCritical = failed.filter((check) => check.critical !== false);
const failedOptional = failed.filter((check) => check.critical === false);
return {
ok: failedCritical.length === 0,
total: checks.length,
passed: checks.length - failed.length,
failed: failed.length,
failedCritical,
failedOptional,
};
}
function timestamp() {
const now = new Date();
const pad = (value) => String(value).padStart(2, '0');
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
'-',
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join('');
}
function createRunRoot(healthDir) {
const runRoot = path.join(healthDir, `run-${timestamp()}`);
fs.mkdirSync(runRoot, { recursive: true });
return runRoot;
}
function checkNode() {
return {
name: 'node-runtime',
ok: true,
critical: true,
message: process.version,
details: { execPath: process.execPath },
};
}
function resolvePlaywrightPackage(requireFn = require) {
const tried = [];
for (const packageName of ['playwright-core', 'playwright']) {
try {
return {
packageName,
module: requireFn(packageName),
tried: [...tried, packageName],
};
} catch (error) {
tried.push(packageName);
}
}
throw new Error(`Cannot find module 'playwright-core' or 'playwright' (tried: ${tried.join(', ')})`);
}
function requirePlaywright() {
return resolvePlaywrightPackage().module;
}
function buildLiveSessionRuntime(config, chromePath = '') {
return {
...(config.browser || {}),
chromeExecutable: chromePath || (config.browser && config.browser.chromeExecutable) || '',
chromeCandidates: (config.browser && config.browser.chromeCandidates) || [],
mainUrl: config.erp && config.erp.mainUrl,
ordersUrl: config.erp && config.erp.ordersUrl,
liveSession: (config.browser && config.browser.liveSession) || {},
};
}
function checkPlaywrightImport() {
try {
const resolved = resolvePlaywrightPackage();
const playwright = resolved.module;
return {
name: 'playwright-import',
ok: Boolean(playwright.chromium),
critical: true,
message: playwright.chromium
? `${resolved.packageName} chromium is available`
: `${resolved.packageName} chromium missing`,
};
} catch (error) {
return {
name: 'playwright-import',
ok: false,
critical: true,
message: error.message,
};
}
}
async function checkBrowserLaunch(config, chromePath, runRoot, deps = {}) {
const liveSessionManager = deps.liveSessionManager || require('./erp_live_session_manager');
const liveRuntime = buildLiveSessionRuntime(config, chromePath);
if (liveSessionManager.isLiveSessionEnabled(liveRuntime)) {
let context = null;
let page = null;
try {
context = await liveSessionManager.connectLiveContext(liveRuntime);
page = await context.newPage();
await page.setContent('<html><body><h1>ERP live session health check</h1></body></html>', {
waitUntil: 'domcontentloaded',
});
await page.screenshot({ path: path.join(runRoot, 'browser-launch.png') });
return {
name: 'browser-launch',
ok: true,
critical: true,
message: `connected to live Chrome at ${liveSessionManager.devToolsEndpoint(liveRuntime)}`,
};
} finally {
if (page && typeof page.close === 'function') await page.close().catch(() => {});
await closeContextBestEffort(context);
}
}
const { chromium } = (deps.requirePlaywright || requirePlaywright)();
const options = {
headless: Boolean(config.browser.headless),
viewport: config.browser.viewport || { width: 1440, height: 900 },
args: Array.isArray(config.browser.launchArgs) ? config.browser.launchArgs : [],
};
if (chromePath) options.executablePath = chromePath;
const context = await chromium.launchPersistentContext(config.browser.profileDir, options);
const page = await context.newPage();
await page.setContent('<html><body><h1>ERP health check</h1></body></html>', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: path.join(runRoot, 'browser-launch.png') });
await context.close();
return {
name: 'browser-launch',
ok: true,
critical: true,
message: 'browser launched with persistent profile',
};
}
async function checkPdfFixture(config, chromePath, runRoot) {
const { chromium } = requirePlaywright();
const options = { headless: true };
if (chromePath) options.executablePath = chromePath;
const browser = await chromium.launch(options);
const page = await browser.newPage();
await page.setContent(
'<html><head><meta charset="utf-8"></head><body><h1>ERP PDF fixture</h1><p>conversion check</p></body></html>',
{ waitUntil: 'domcontentloaded' }
);
const pdfPath = path.join(runRoot, 'pdf-fixture.pdf');
await page.pdf({ path: pdfPath, format: 'A4', printBackground: true });
await browser.close();
const stat = fs.statSync(pdfPath);
return {
name: 'pdf-fixture',
ok: stat.size > 1024,
critical: true,
message: stat.size > 1024 ? `created ${stat.size} bytes` : `too small: ${stat.size} bytes`,
details: { pdfPath, bytes: stat.size, converter: config.pdf.converter },
};
}
function timeoutAfter(ms, message) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
async function withTimeout(promise, ms, message) {
return Promise.race([promise, timeoutAfter(ms, message)]);
}
async function closeContextBestEffort(context) {
if (!context) return;
await Promise.race([
context.close().catch(() => {}),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
}
async function checkErpSession(config, chromePath, runRoot, deps = {}) {
const liveSessionManager = deps.liveSessionManager || require('./erp_live_session_manager');
const liveRuntime = buildLiveSessionRuntime(config, chromePath);
const { chromium } = (deps.requirePlaywright || requirePlaywright)();
const options = {
headless: Boolean(config.browser.headless),
viewport: config.browser.viewport || { width: 1440, height: 900 },
args: Array.isArray(config.browser.launchArgs) ? config.browser.launchArgs : [],
};
if (chromePath) options.executablePath = chromePath;
let context = null;
let page = null;
let navigationError = '';
const dialogs = [];
let dialogState = { messages: [] };
try {
if (liveSessionManager.isLiveSessionEnabled(liveRuntime)) {
context = await withTimeout(
liveSessionManager.connectLiveContext(liveRuntime),
20000,
'ERP live browser connection timed out'
);
} else {
context = await withTimeout(
chromium.launchPersistentContext(config.browser.profileDir, options),
20000,
'ERP browser launch timed out'
);
}
page = context.pages()[0] || await context.newPage();
dialogState = installErpDialogHandler(page, (record) => dialogs.push(record));
await page.goto(config.erp.mainUrl, { waitUntil: 'domcontentloaded', timeout: 12000 }).catch((error) => {
navigationError = error.message;
});
await page.waitForTimeout(1000).catch(() => {});
await page.screenshot({ path: path.join(runRoot, 'erp-session.png'), fullPage: true, timeout: 8000 }).catch(() => {});
const bodyText = page ? await page.locator('body').innerText({ timeout: 3000 }).catch(() => '') : '';
const classification = classifyErpSession({
bodyText,
dialogMessage: dialogState.messages.map((item) => item.message).join('\n'),
url: page ? page.url() : config.erp.mainUrl,
});
return {
name: 'erp-session',
ok: classification.ok,
critical: true,
message: classification.message,
details: {
mainUrl: config.erp.mainUrl,
reason: classification.reason,
navigationError,
dialogs,
...classification.details,
},
};
} finally {
await closeContextBestEffort(context);
}
}
function parseArgs(argv) {
const args = {
config: path.join('config', 'erp-deployment.example.json'),
erp: false,
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--config') {
args.config = argv[index + 1];
index += 1;
} else if (arg.startsWith('--config=')) {
args.config = arg.slice('--config='.length);
} else if (arg === '--erp') {
args.erp = true;
} else if (arg === '--no-erp') {
args.erp = false;
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
args.help = true;
}
}
return args;
}
function helpText() {
return [
'Usage: node tools/erp_deployment_health_check.js [--config path] [--erp|--no-erp] [--json]',
'',
'Checks local deployment prerequisites for thai-erp-order-entry.',
'--erp opens the ERP main page with the configured persistent profile but does not save orders.',
].join('\n');
}
async function runHealthCheck(options = {}) {
const workspaceRoot = options.workspaceRoot || process.cwd();
const configPath = path.isAbsolute(options.configPath)
? options.configPath
: path.join(workspaceRoot, options.configPath || path.join('config', 'erp-deployment.example.json'));
const config = loadConfig(configPath, workspaceRoot);
const runRoot = createRunRoot(config.paths.healthDir);
const checks = [];
checks.push(checkNode());
for (const dir of [
config.paths.workRoot,
config.paths.downloadDir,
config.paths.outputDir,
config.paths.auditDir,
config.paths.logDir,
config.paths.healthDir,
config.browser.profileDir,
]) {
try {
const result = ensureWritableDir(dir);
checks.push({ name: `writable:${path.basename(dir)}`, ok: result.ok, critical: true, details: result });
} catch (error) {
checks.push({ name: `writable:${path.basename(dir)}`, ok: false, critical: true, message: error.message });
}
}
const chromeCandidates = config.browser.chromeExecutable
? [config.browser.chromeExecutable, ...(config.browser.chromeCandidates || [])]
: config.browser.chromeCandidates || [];
const chrome = selectExistingPath(chromeCandidates);
checks.push({
name: 'chrome-or-edge',
ok: chrome.ok,
critical: false,
message: chrome.ok ? chrome.path : 'no Chrome/Edge candidate found; Playwright bundled browser may still work',
details: chrome,
});
const playwrightImport = checkPlaywrightImport();
checks.push(playwrightImport);
const chromePath = chrome.ok ? chrome.path : '';
if (playwrightImport.ok) {
try {
checks.push(await checkBrowserLaunch(config, chromePath, runRoot));
} catch (error) {
checks.push({ name: 'browser-launch', ok: false, critical: true, message: error.message });
}
try {
checks.push(await checkPdfFixture(config, chromePath, runRoot));
} catch (error) {
checks.push({ name: 'pdf-fixture', ok: false, critical: true, message: error.message });
}
if (options.erp) {
try {
checks.push(await checkErpSession(config, chromePath, runRoot));
} catch (error) {
checks.push({ name: 'erp-session', ok: false, critical: true, message: error.message });
}
} else {
checks.push({ name: 'erp-session', ok: true, critical: false, message: 'skipped; pass --erp to verify login' });
}
}
const summary = summarizeChecks(checks);
const report = {
timestamp: new Date().toISOString(),
workspaceRoot,
configPath,
runRoot,
summary,
checks,
config: {
erp: config.erp,
browser: {
profileDir: config.browser.profileDir,
launchArgs: config.browser.launchArgs || [],
interactiveLoginWaitMs: config.browser.interactiveLoginWaitMs,
headless: config.browser.headless,
viewport: config.browser.viewport,
liveSession: config.browser.liveSession,
},
paths: config.paths,
pdf: config.pdf,
delivery: config.delivery,
safety: config.safety,
},
};
fs.writeFileSync(path.join(runRoot, 'health.json'), JSON.stringify(report, null, 2), 'utf8');
return report;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(helpText());
return;
}
const report = await runHealthCheck({ configPath: args.config, erp: args.erp });
if (args.json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(`Health report: ${path.join(report.runRoot, 'health.json')}`);
console.log(`Summary: ${report.summary.passed}/${report.summary.total} checks passed`);
for (const check of report.checks) {
const label = check.ok ? 'OK ' : 'FAIL';
const critical = check.critical === false ? 'optional' : 'critical';
console.log(`[${label}] ${check.name} (${critical}) ${check.message || ''}`.trim());
}
}
if (!report.summary.ok) {
process.exitCode = 1;
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exitCode = 1;
});
}
module.exports = {
DEFAULT_CONFIG,
normalizeConfig,
loadConfig,
selectExistingPath,
ensureWritableDir,
summarizeChecks,
createRunRoot,
parseArgs,
resolvePlaywrightPackage,
buildLiveSessionRuntime,
checkBrowserLaunch,
checkErpSession,
isLoginRequiredText,
classifyErpSession,
runHealthCheck,
};