#!/usr/bin/env node const DEFAULT_BASE_URL = "https://oa.hyinda.com"; function parseArgs(argv) { const options = { baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL, json: false, month: new Date().toISOString().slice(0, 7), }; for (const arg of argv) { if (arg === "--json") { options.json = true; } else if (arg.startsWith("--base-url=")) { options.baseUrl = arg.slice("--base-url=".length); } else if (arg.startsWith("--month=")) { options.month = arg.slice("--month=".length); } } options.baseUrl = options.baseUrl.trim().replace(/\/$/, ""); if (!/^\d{4}-\d{2}$/.test(options.month)) { throw new Error(`invalid month: ${options.month}`); } return options; } function today() { return new Date().toISOString().slice(0, 10); } function getCredential(role) { const prefixByRole = { admin: "LIVE_SMOKE_ADMIN", employee: "LIVE_SMOKE_EMPLOYEE", }; const prefix = prefixByRole[role]; const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim(); const password = process.env[`${prefix}_PASSWORD`]?.trim(); if (!identifier || !password) { return null; } return { identifier, password }; } async function fetchJson(url, init = {}) { const response = await fetch(url, { ...init, signal: AbortSignal.timeout(25_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 }; } function errorSummary(json) { const error = json?.error ?? {}; const details = error.details ?? {}; return { code: typeof error.code === "string" ? error.code : null, featureFlag: typeof details.featureFlag === "string" ? details.featureFlag : null, reason: typeof details.reason === "string" ? details.reason : null, }; } async function login(baseUrl, role) { const credential = getCredential(role); if (!credential) { return { ok: false, status: "SKIP", role, reason: "missing_credentials" }; } const result = await fetchJson(`${baseUrl}/api/auth/login`, { method: "POST", headers: { "content-type": "application/json", "x-device-fingerprint": `launch-auto-attendance-${role}`, }, body: JSON.stringify({ identifier: credential.identifier, password: credential.password, }), }); const accessToken = result.json?.data?.accessToken; if (!result.ok || typeof accessToken !== "string" || !accessToken) { return { ok: false, status: "FAIL", role, httpStatus: result.status, error: errorSummary(result.json), }; } return { ok: true, status: "PASS", role, accessToken }; } async function callApi(baseUrl, token, method, pathName, body) { return fetchJson(`${baseUrl}${pathName}`, { method, headers: { authorization: `Bearer ${token}`, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); } function passStep(name, details = {}) { return { name, status: "PASS", ...details }; } function failStep(name, details = {}) { return { name, status: "FAIL", ...details }; } function skipStep(name, details = {}) { return { name, status: "SKIP", ...details }; } function printHuman(summary) { console.log(`[live-attendance-unified] status=${summary.status} base=${summary.baseUrl} month=${summary.month}`); for (const step of summary.steps) { const detail = step.httpStatus ? ` http=${step.httpStatus}` : ""; const code = step.code ? ` code=${step.code}` : ""; console.log(`[live-attendance-unified] ${step.name} status=${step.status}${detail}${code}`); } } async function main() { const options = parseArgs(process.argv.slice(2)); const employee = await login(options.baseUrl, "employee"); const admin = await login(options.baseUrl, "admin"); const steps = []; if (!employee.ok) { steps.push(employee.status === "SKIP" ? skipStep("employee-login", employee) : failStep("employee-login", employee)); } else { steps.push(passStep("employee-login")); const clockIn = await callApi(options.baseUrl, employee.accessToken, "POST", "/api/attendance/clock-in", {}); const clockError = errorSummary(clockIn.json); if (clockIn.status === 409 && clockError.featureFlag === "attendance_v1_disabled") { steps.push(passStep("legacy-clock-in-disabled", { httpStatus: clockIn.status, featureFlag: clockError.featureFlag, reason: clockError.reason, })); } else { steps.push(failStep("legacy-clock-in-disabled", { httpStatus: clockIn.status, error: clockError, })); } const monthly = await callApi( options.baseUrl, employee.accessToken, "GET", `/api/attendance/monthly?month=${encodeURIComponent(options.month)}`, ); if (monthly.ok) { const data = monthly.json?.data ?? {}; steps.push(passStep("attendance-monthly", { httpStatus: monthly.status, employeeCount: data.summary?.employeeCount ?? null, workDays: data.summary?.workDays ?? null, checkinDays: data.summary?.checkinDays ?? null, missingDays: data.summary?.missingDays ?? null, })); } else { steps.push(failStep("attendance-monthly", { httpStatus: monthly.status, error: errorSummary(monthly.json), })); } const corrections = await callApi( options.baseUrl, employee.accessToken, "GET", `/api/attendance-corrections?month=${encodeURIComponent(options.month)}&page=1&pageSize=5`, ); if (corrections.ok) { const data = corrections.json?.data ?? {}; steps.push(passStep("attendance-corrections-list", { httpStatus: corrections.status, total: data.total ?? null, itemCount: Array.isArray(data.items) ? data.items.length : null, })); } else { steps.push(failStep("attendance-corrections-list", { httpStatus: corrections.status, error: errorSummary(corrections.json), })); } } if (!admin.ok) { steps.push(admin.status === "SKIP" ? skipStep("admin-login", admin) : failStep("admin-login", admin)); } else { steps.push(passStep("admin-login")); const syncRuns = await callApi(options.baseUrl, admin.accessToken, "GET", "/api/attendance/wecom/sync-runs?page=1&pageSize=5"); if (syncRuns.ok) { const data = syncRuns.json?.data ?? {}; steps.push(passStep("wecom-sync-runs", { httpStatus: syncRuns.status, total: data.total ?? null, itemCount: Array.isArray(data.items) ? data.items.length : null, })); } else { steps.push(failStep("wecom-sync-runs", { httpStatus: syncRuns.status, error: errorSummary(syncRuns.json), })); } if (process.env.LAUNCH_AUTO_RUN_WECOM_SYNC === "1") { const sync = await callApi(options.baseUrl, admin.accessToken, "POST", "/api/attendance/wecom/sync", { startDate: today(), endDate: today(), }); if (sync.ok && sync.json?.data?.skipped) { steps.push(skipStep("wecom-checkin-sync", { httpStatus: sync.status, code: sync.json?.data?.code ?? null, employeeCount: sync.json?.data?.employeeCount ?? null, boundUserCount: sync.json?.data?.boundUserCount ?? null, recordCount: sync.json?.data?.recordCount ?? null, })); } else if (sync.ok) { steps.push(passStep("wecom-checkin-sync", { httpStatus: sync.status, code: sync.json?.data?.code ?? null, employeeCount: sync.json?.data?.employeeCount ?? null, boundUserCount: sync.json?.data?.boundUserCount ?? null, recordCount: sync.json?.data?.recordCount ?? null, })); } else { steps.push(failStep("wecom-checkin-sync", { httpStatus: sync.status, error: errorSummary(sync.json), })); } } else { steps.push(skipStep("wecom-checkin-sync", { reason: "LAUNCH_AUTO_RUN_WECOM_SYNC not set" })); } } const failed = steps.some((step) => step.status === "FAIL"); const summary = { checkedAt: new Date().toISOString(), baseUrl: options.baseUrl, month: options.month, status: failed ? "FAIL" : "PASS", steps, }; if (options.json) { console.log(JSON.stringify(summary, null, 2)); } else { printHuman(summary); } if (failed) { process.exit(1); } } main().catch((error) => { console.error(`[live-attendance-unified][ERROR] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });