562 lines
17 KiB
JavaScript
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,
|
|
};
|