#!/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('
conversion check
', { 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, };