import * as core from "@actions/core"; import { DefaultArtifactClient } from "@actions/artifact"; import fs from "fs"; import path from "path"; import os from "os"; // ──────────────────────────────────────────────────────────────────────────── // Shard / subscribe-path helpers // ──────────────────────────────────────────────────────────────────────────── /** * Returns true if the hostname is the official Screeps server. * Used to decide whether to prefix the subscribe path with a shard name. * * @param {string} hostname - e.g. "screeps.com" or "builder64" * @returns {boolean} */ export function isOfficialServer(hostname) { return hostname === "screeps.com"; } /** * Builds the channel path argument passed to socket.subscribe(). * * The screeps-api socket automatically prefixes `user:/` when the path * does not match the `type:id` pattern, so we only supply the channel part: * If shard is provided → "/console" * Official server → "shard0/console" (if no shard provided) * Private server → "console" (if no shard provided) * * @param {string} hostname * @param {string} [shard] * @returns {string} */ export function buildSubscribePath(hostname, shard) { if (shard) return `${shard}/console`; return isOfficialServer(hostname) ? "shard0/console" : "console"; } // ──────────────────────────────────────────────────────────────────────────── // Detection helpers // ──────────────────────────────────────────────────────────────────────────── /** * Returns true when errorText contains JavaScript stack-frame lines. * Screeps places runtime errors (including stack traces) in event.data.error. * A traceback is identified by lines beginning with four spaces followed by "at ". * * @param {string|null|undefined} errorText - Contents of event.data.error * @returns {boolean} */ export function detectTraceback(errorText) { if (!errorText) return false; return /^\s{4}at /m.test(errorText); } /** * Returns true when any log line contains Screeps console.warn markup. * Screeps wraps console.warn() output in orange or yellow HTML tags. * * @param {string[]|null|undefined} logLines - Contents of event.data.messages.log * @returns {boolean} */ export function detectWarning(logLines) { if (!logLines || logLines.length === 0) return false; const warnPattern = / warnPattern.test(line)); } // ──────────────────────────────────────────────────────────────────────────── // Output helpers // ──────────────────────────────────────────────────────────────────────────── /** * Builds a CI-friendly progress string for core.info(). * * @param {number} elapsed - Ticks elapsed since monitoring started * @param {number} total - Total ticks to monitor * @returns {string} - e.g. "[Monitor] 10/50 ticks elapsed..." */ export function buildProgressMessage(elapsed, total) { return `[Monitor] ${elapsed}/${total} ticks elapsed...`; } // ──────────────────────────────────────────────────────────────────────────── // File / artifact helpers // ──────────────────────────────────────────────────────────────────────────── /** * Writes an array of log lines to a UTF-8 text file, one line per entry. * * @param {string[]} lines - Lines to write * @param {string} filePath - Absolute path to write to * @returns {Promise} */ export async function writeLogFile(lines, filePath) { await fs.promises.writeFile(filePath, lines.join("\n"), "utf8"); } /** * Uploads a file as a named workflow artifact using @actions/artifact. * Degrades gracefully to core.warning() if the runner does not support the * artifact service (e.g. a bare self-hosted runner without the service configured). * * @param {string} filePath - Absolute path of the file to upload * @param {string} [artifactName="screeps-console-log"] - Artifact display name * @returns {Promise} */ export async function uploadLogArtifact( filePath, artifactName = "screeps-console-log", ) { try { const client = new DefaultArtifactClient(); await client.uploadArtifact( artifactName, [filePath], path.dirname(filePath), ); core.info(`[Monitor] Console log uploaded as artifact '${artifactName}'.`); } catch (err) { core.warning( `[Monitor] Could not upload console log as artifact: ${err.message}`, ); } } // ──────────────────────────────────────────────────────────────────────────── // Console event handler // ──────────────────────────────────────────────────────────────────────────── /** * Processes a single 'console' WebSocket event from the Screeps socket. * Mutates `state` and `stdoutBuffer` in place; never throws. * * WebSocket event.data shape: * { * messages: { * log: string[], // stdout (console.warn included with HTML markup) * results: string[], // return values of console-evaluated expressions * }, * error: string | null, // stderr, runtime errors, tracebacks * } * * Behaviour: * - Warn lines (orange/yellow tags) → always core.warning() (live), * sets state.sawWarningLog, still included in stdoutBuffer / core.info(). * - Error field → always core.error() (live), sets state.sawErrorLog. * If a stack frame is detected → also sets state.sawTraceback. * - All stdout lines → core.info() when logToFile=false, * pushed to stdoutBuffer when logToFile=true. * * @param {{ data?: { messages?: { log?: string[], results?: string[] }, error?: string } }} event * @param {{ logToFile: boolean }} opts * @param {string[]} stdoutBuffer - Mutable buffer used in logToFile mode * @param {{ sawTraceback: boolean, sawErrorLog: boolean, sawWarningLog: boolean }} state */ export function handleConsoleEvent(event, opts, stdoutBuffer, state) { const { logToFile } = opts; const data = event?.data ?? {}; const logLines = data?.messages?.log ?? []; const results = data?.messages?.results ?? []; const errorText = data?.error ?? null; // ── Warn detection (always live regardless of logToFile) ───────────────── if (detectWarning(logLines)) { state.sawWarningLog = true; const warnPattern = / warnPattern.test(l)).forEach((l) => core.warning(l)); } // ── Stdout lines ────────────────────────────────────────────────────────── const allStdout = [...logLines, ...results]; if (allStdout.length > 0) { if (logToFile) { stdoutBuffer.push(...allStdout); } else { allStdout.forEach((l) => core.info(l)); } } // ── Error field (always live) ───────────────────────────────────────────── if (errorText) { state.sawErrorLog = true; core.error(errorText); if (detectTraceback(errorText)) { state.sawTraceback = true; } } } // ──────────────────────────────────────────────────────────────────────────── // Tick polling // ──────────────────────────────────────────────────────────────────────────── /** * Sleeps for the given number of milliseconds. * * @param {number} ms * @returns {Promise} */ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Polls GET /api/game/time every `intervalMs` milliseconds until the tick * delta (currentTick - startTick) reaches or exceeds `targetTicks`. * Calls `onProgress(elapsed, targetTicks)` on every poll so the caller can * log progress at whatever cadence it chooses. * * @param {import('screeps-api').ScreepsAPI} api * @param {number} startTick - Tick number recorded before monitoring started * @param {number} targetTicks - Stop when (currentTick - startTick) >= this * @param {string|undefined} shard - "shard0" for official, undefined for private * @param {number} intervalMs - Poll interval in milliseconds * @param {(elapsed: number, total: number) => void} onProgress * @returns {Promise} Final elapsed tick count */ export async function pollUntilDone( api, startTick, targetTicks, shard, intervalMs, onProgress, shouldStop = () => false, ) { let elapsed = 0; while (elapsed < targetTicks && !shouldStop()) { await sleep(intervalMs); const { time } = await api.time(shard); elapsed = time - startTick; onProgress(elapsed, targetTicks); } return elapsed; } // ──────────────────────────────────────────────────────────────────────────── // Main orchestrator // ──────────────────────────────────────────────────────────────────────────── /** * @typedef {Object} MonitorOptions * @property {number} monitor - Number of game ticks to collect. * @property {boolean} logToFile - Buffer stdout to artifact instead of streaming. * @property {'ignore'|'warn'|'fail'} onTraceback - Action on traceback detection. * @property {'ignore'|'warn'|'fail'} onErrorLog - Action on any error-console output. * @property {'ignore'|'warn'|'fail'} onWarningLog - Action on console.warn output. * @property {number} monitorInterval - Print a progress update every N ticks. * @property {string} hostname - Screeps hostname (for shard derivation). * @property {string} [shard] - Optional shard to monitor. */ /** * @typedef {Object} MonitorResult * @property {boolean} sawTraceback - True if a JS stack trace was detected. * @property {boolean} sawErrorLog - True if the error console had any output. * @property {boolean} sawWarningLog - True if console.warn output was detected. */ /** * Monitors the Screeps console for a given number of game ticks after a deploy. * * Flow: * 1. Fetch startTick via GET /api/game/time (REST poll). * 2. Connect WebSocket and subscribe to the console channel. * 3. Run the tick-poll loop (500 ms interval) concurrently with the socket * event listener; the poll loop drives the stop condition. * 4. On each 'console' WebSocket event, delegate to handleConsoleEvent(). * 5. When poll finishes, disconnect socket cleanly (in a finally block). * 6. If logToFile=true: write buffered stdout to a temp file and upload artifact. * 7. Return MonitorResult. * * @param {import('screeps-api').ScreepsAPI} api * @param {MonitorOptions} opts * @returns {Promise} */ export async function monitorConsole(api, opts) { const { monitor, logToFile, monitorInterval, hostname, shard: providedShard, } = opts; // Use provided shard, or fall back to shard0 for official, or undefined for private const shard = providedShard || (isOfficialServer(hostname) ? "shard0" : undefined); const subscribePath = buildSubscribePath(hostname, providedShard); // Shared mutable state — updated by handleConsoleEvent via event listener const stdoutBuffer = []; const state = { sawTraceback: false, sawErrorLog: false, sawWarningLog: false, }; let lastProgressTick = 0; // ── Step 1: record starting tick ───────────────────────────────────────── const { time: startTick } = await api.time(shard); // ── Step 2: connect socket + subscribe ─────────────────────────────────── await api.socket.connect(); await api.socket.subscribe(subscribePath, (event) => { handleConsoleEvent(event, opts, stdoutBuffer, state); }); core.info( `[Monitor] Watching Screeps console for ${monitor} ticks` + (shard ? ` on ${shard}` : "") + "...", ); // ── Step 3 & 4: tick-poll loop ─────────────────────────────────────────── try { await pollUntilDone( api, startTick, monitor, shard, 500, (elapsed, total) => { // Print progress at configured interval boundaries if ( elapsed > 0 && elapsed >= lastProgressTick + monitorInterval && elapsed <= total ) { core.info(buildProgressMessage(elapsed, total)); lastProgressTick = elapsed; } }, () => { // Fail-fast logic: stop monitoring if any 'fail' action is triggered if (opts.onTraceback === "fail" && state.sawTraceback) return true; if (opts.onErrorLog === "fail" && state.sawErrorLog) return true; if (opts.onWarningLog === "fail" && state.sawWarningLog) return true; return false; }, ); } finally { // ── Step 5: always disconnect cleanly ──────────────────────────────── api.socket.disconnect(); } // ── Step 6: artifact upload ─────────────────────────────────────────────── if (logToFile && stdoutBuffer.length > 0) { const tmpFile = path.join(os.tmpdir(), "screeps_console_log.txt"); await writeLogFile(stdoutBuffer, tmpFile); await uploadLogArtifact(tmpFile); } else if (logToFile) { core.info( "[Monitor] No stdout lines were collected; skipping artifact upload.", ); } core.info( `[Monitor] Done. sawTraceback=${state.sawTraceback} sawErrorLog=${state.sawErrorLog} sawWarningLog=${state.sawWarningLog}`, ); return { sawTraceback: state.sawTraceback, sawErrorLog: state.sawErrorLog, sawWarningLog: state.sawWarningLog, }; }