import * as core from "@actions/core"; import { create } from "@actions/artifact"; import fs from "fs"; import path from "path"; import os from "os"; /** * 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) { // The console channel on Screeps official and most private servers is 'console'. // We subscribe to the aggregate feed and filter by shard in handleConsoleEvent. return "console"; } /** * 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; const text = safeDecode(errorText); return /^\s{4}at /m.test(text); } /** * 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)); } /** * Safely decodes a URI-encoded string from the Screeps console. * Returns the original string if decoding fails or if no '%' is present. * * @param {string} text * @returns {string} */ export function safeDecode(text) { if (typeof text !== "string") return text; let result = text; if (result.includes("%")) { try { result = decodeURIComponent(result); } catch (err) { // Ignore decoding errors } } // Screeps console often contains HTML entities return result .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&/g, "&"); } /** * Outputs text to the action log, splitting by newline to ensure proper display. * Optionally prepends a [shard] prefix. * * @param {string} text * @param {"info"|"warning"|"error"} level * @param {string} [shard] */ export function outputMultiline(text, level = "info", shard = null) { if (!text) return; const prefix = shard ? `[${shard}] ` : ""; const lines = text.split(/\r?\n/); lines.forEach((line) => { const formattedLine = `${prefix}${line}`; if (level === "error") core.error(formattedLine); else if (level === "warning") core.warning(formattedLine); else core.info(formattedLine); }); } /** * 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...`; } /** * 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 one or more files as a named workflow artifact using @actions/artifact. * Degrades gracefully to core.warning() if the runner does not support the * artifact service. * * @param {string[]} filePaths - Absolute paths of the files to upload * @param {string} [artifactName="screeps-console-log"] - Artifact display name * @returns {Promise} */ export async function uploadLogArtifacts( filePaths, artifactName = "screeps-console-log", ) { if (!filePaths || filePaths.length === 0) return; try { const client = create(); const rootDir = path.dirname(filePaths[0]); await client.uploadArtifact(artifactName, filePaths, rootDir, { continueOnError: true, }); core.info(`[Monitor] Console logs uploaded as artifact '${artifactName}'.`); } catch (err) { core.warning( `[Monitor] Could not upload console logs as artifact: ${err.message}`, ); } } /** * 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, shard?: string } }} event * @param {{ logToFile: boolean, shard?: string }} opts * @param {Record} shardBuffers - Mutable buffer Map used in logToFile mode * @param {{ sawTraceback: boolean, sawErrorLog: boolean, sawWarningLog: boolean }} state */ export function handleConsoleEvent(event, opts, shardBuffers, state) { const { logToFile, shard: targetShard } = opts; const data = event?.data ?? {}; // Shard filtering: If a shard is specified in opts, only process messages from that shard. // Official server events include a 'shard' property in event.data. if (targetShard && data.shard && data.shard !== targetShard) { return; } 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) => outputMultiline(safeDecode(l), "warning", data.shard)); } // Traceback detection in log lines (Screeps sometimes sends errors here) if (logLines.some((l) => detectTraceback(l))) { state.sawTraceback = true; state.sawErrorLog = true; } // Stdout lines const allStdout = [...logLines, ...results].map(safeDecode); if (allStdout.length > 0) { if (logToFile) { const shard = data.shard || "default"; if (!shardBuffers[shard]) shardBuffers[shard] = []; shardBuffers[shard].push(...allStdout); } else { allStdout.forEach((l) => outputMultiline(l, "info", data.shard)); } } // Error field (always live) if (errorText) { state.sawErrorLog = true; const decodedError = safeDecode(errorText); outputMultiline(decodedError, "error", data.shard); if (detectTraceback(decodedError)) { state.sawTraceback = true; } } } /** * 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; } /** * @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 shardBuffers = {}; // { [shardName]: string[] } 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, shardBuffers, 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 const shardKeys = Object.keys(shardBuffers); if (logToFile && shardKeys.length > 0) { const tmpDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), "screeps-monitor-"), ); const filePaths = []; for (const sName of shardKeys) { const fileName = sName === "default" ? "screeps_console_log.txt" : `${sName}_console_log.txt`; const tmpFile = path.join(tmpDir, fileName); await writeLogFile(shardBuffers[sName], tmpFile); filePaths.push(tmpFile); } await uploadLogArtifacts(filePaths); } 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, }; }