#!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const DEFAULT_BASE_URL = "https://oa.hyinda.com"; const DEFAULT_REPORT_ROOT = "artifacts/qa-flow"; function parseArgs(argv) { const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const options = { baseUrl: (process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, ""), live: process.env.WECOM_HR_MASTER_SYNC_LIVE === "1", json: process.env.WECOM_HR_MASTER_SYNC_JSON === "1", applyApproved: process.env.WECOM_HR_MASTER_SYNC_APPLY_APPROVED === "1", limit: Number(process.env.WECOM_HR_MASTER_SYNC_LIMIT || 10), userids: csvValues(process.env.WECOM_HR_MASTER_SYNC_USERIDS || ""), reportDir: process.env.WECOM_HR_MASTER_SYNC_REPORT_DIR || join(DEFAULT_REPORT_ROOT, `wecom-hr-master-sync-${stamp}`), }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--live") options.live = true; if (arg === "--json") options.json = true; if (arg === "--apply-approved") options.applyApproved = true; if (arg === "--limit") { options.limit = Number(argv[index + 1] || options.limit); index += 1; } else if (arg.startsWith("--limit=")) { options.limit = Number(arg.slice("--limit=".length)); } else if (arg === "--userids") { options.userids = csvValues(argv[index + 1] || ""); index += 1; } else if (arg.startsWith("--userids=")) { options.userids = csvValues(arg.slice("--userids=".length)); } else if (arg.startsWith("--report-dir=")) { options.reportDir = arg.slice("--report-dir=".length); } } options.limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(50, Math.floor(options.limit))) : 10; return options; } function csvValues(value) { return String(value || "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function maskId(value) { const raw = String(value || "").trim(); if (!raw) return null; if (raw.length <= 6) return `${raw.slice(0, 2)}***`; return `${raw.slice(0, Math.min(8, raw.length))}***`; } function getCredential() { const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim(); const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim(); return identifier && password ? { identifier, password } : null; } async function fetchJson(url, init = {}) { try { const response = await fetch(url, { ...init, headers: { ...(init.headers || {}), ...(init.body ? { "content-type": "application/json" } : {}), }, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } return { ok: response.ok, status: response.status, json }; } catch (error) { return { ok: false, status: 0, json: null, error: error instanceof Error ? error.message : String(error), }; } } function summarizeError(json) { return { code: json?.error?.code ?? null, message: String(json?.error?.message ?? "").replace(/[A-Za-z0-9_-]{24,}/g, "***").slice(0, 160), details: json?.error?.details ?? null, }; } async function login(baseUrl, credential) { const result = await fetchJson(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "x-device-fingerprint": "whmslive-hr-master-sync", }, body: JSON.stringify({ identifier: credential.identifier, password: credential.password, }), }); if (!result.ok || !result.json?.data?.accessToken) { throw new Error(`admin login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim()); } return result.json.data.accessToken; } async function callApi(baseUrl, token, method, path, body) { const result = await fetchJson(`${baseUrl}${path}`, { method, headers: { authorization: `Bearer ${token}` }, body: body == null ? undefined : JSON.stringify(body), }); return result; } function step(status, name, details = {}) { return { status, name, details }; } function buildPayload(options, mode) { return { mode, limit: options.limit, ...(options.userids.length ? { userids: options.userids } : {}), }; } function sanitizeRun(run) { if (!run) return null; return { id: run.id, mode: run.mode, status: run.status, summary: run.summary ?? null, errorCode: run.errorCode ?? null, requestedUserids: Array.isArray(run.requestedUserids) ? run.requestedUserids.map(maskId) : null, itemCount: run.itemCount ?? run.items?.length ?? null, }; } function isAcceptableRun(run) { return run?.status === "success" || run?.status === "partial"; } function isSkippedRun(run) { return run?.status === "skipped"; } async function execute(options) { const startedAt = new Date().toISOString(); const steps = []; if (!options.live) { return { status: "SKIP", conclusion: "LIVE_DISABLED", startedAt, finishedAt: new Date().toISOString(), steps: [step("SKIP", "live-disabled", { runWith: "WECOM_HR_MASTER_SYNC_LIVE=1" })], inputs: { userids: options.userids.map(maskId), limit: options.limit, }, }; } const credential = getCredential(); if (!credential) { return { status: "SKIP", conclusion: "MISSING_LIVE_SMOKE_ADMIN", startedAt, finishedAt: new Date().toISOString(), steps: [step("SKIP", "missing-admin-credential")], }; } const token = await login(options.baseUrl, credential); steps.push(step("PASS", "admin-login")); const list = await callApi(options.baseUrl, token, "GET", "/api/employees/wecom-hr-master-sync/runs?pageSize=1"); if (!list.ok) { const error = summarizeError(list.json); const featureFlag = error.details?.featureFlag; return { status: featureFlag ? "SKIP" : "FAIL", conclusion: featureFlag ? "FEATURE_DISABLED_OR_UNAVAILABLE" : "RUN_LIST_FAILED", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step(featureFlag ? "SKIP" : "FAIL", "list-runs", { status: list.status, error })], }; } steps.push(step("PASS", "list-runs", { latest: sanitizeRun(list.json?.data?.items?.[0]) })); const dryRun = await callApi( options.baseUrl, token, "POST", "/api/employees/wecom-hr-master-sync/runs", buildPayload(options, "dry_run"), ); if (!dryRun.ok) { return { status: "FAIL", conclusion: "DRY_RUN_FAILED", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step("FAIL", "dry-run", { status: dryRun.status, error: summarizeError(dryRun.json) })], }; } const dryRunSummary = sanitizeRun(dryRun.json?.data); if (!isAcceptableRun(dryRunSummary)) { const status = isSkippedRun(dryRunSummary) ? "SKIP" : "FAIL"; return { status, conclusion: isSkippedRun(dryRunSummary) ? "DRY_RUN_SKIPPED" : "DRY_RUN_FAILED", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step(status, "dry-run", dryRunSummary)], }; } steps.push(step("PASS", "dry-run", dryRunSummary)); if (!options.applyApproved) { return { status: "PASS", conclusion: "DRY_RUN_ONLY", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step("SKIP", "apply", { runWith: "WECOM_HR_MASTER_SYNC_APPLY_APPROVED=1" })], }; } if (!options.userids.length) { return { status: "FAIL", conclusion: "APPLY_REQUIRES_EXPLICIT_USERIDS", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step("FAIL", "apply-guard", { reason: "missing_explicit_userids" })], }; } const apply = await callApi( options.baseUrl, token, "POST", "/api/employees/wecom-hr-master-sync/runs", buildPayload(options, "apply"), ); if (!apply.ok) { return { status: "FAIL", conclusion: "APPLY_FAILED", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step("FAIL", "apply", { status: apply.status, error: summarizeError(apply.json) })], }; } const applySummary = sanitizeRun(apply.json?.data); if (!isAcceptableRun(applySummary)) { const status = isSkippedRun(applySummary) ? "SKIP" : "FAIL"; return { status, conclusion: isSkippedRun(applySummary) ? "APPLY_SKIPPED" : "APPLY_FAILED", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step(status, "apply", applySummary)], }; } return { status: "PASS", conclusion: "APPLY_DONE", startedAt, finishedAt: new Date().toISOString(), steps: [...steps, step("PASS", "apply", applySummary)], }; } async function main() { const options = parseArgs(process.argv.slice(2)); mkdirSync(options.reportDir, { recursive: true }); const summary = await execute(options); const reportPath = join(options.reportDir, "summary.json"); writeFileSync(reportPath, `${JSON.stringify(summary, null, 2)}\n`); if (options.json) { console.log(JSON.stringify(summary, null, 2)); } else { console.log(`[wecom-hr-master-sync] status=${summary.status} conclusion=${summary.conclusion}`); console.log(`[wecom-hr-master-sync] report=${reportPath}`); } if (summary.status === "FAIL") process.exit(1); } main().catch((error) => { console.error(`[wecom-hr-master-sync][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });