421 lines
17 KiB
JavaScript
421 lines
17 KiB
JavaScript
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:<id>/` when the path
|
|
* does not match the `type:id` pattern, so we only supply the channel part:
|
|
* If shard is provided → "<shard>/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;
|
|
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 <font> 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 = /<font\s+color=['"](?:orange|yellow)['"]/i;
|
|
return logLines.some((line) => 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.
|
|
*
|
|
* @param {string} text
|
|
* @param {"info"|"warning"|"error"} level
|
|
*/
|
|
export function outputMultiline(text, level = "info") {
|
|
if (!text) return;
|
|
const lines = text.split(/\r?\n/);
|
|
lines.forEach((line) => {
|
|
if (level === "error") core.error(line);
|
|
else if (level === "warning") core.warning(line);
|
|
else core.info(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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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 <font> 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 = /<font\s+color=['"](?:orange|yellow)['"]/i;
|
|
logLines
|
|
.filter((l) => warnPattern.test(l))
|
|
.forEach((l) => outputMultiline(safeDecode(l), "warning"));
|
|
}
|
|
|
|
// ── 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) {
|
|
stdoutBuffer.push(...allStdout);
|
|
} else {
|
|
allStdout.forEach((l) => outputMultiline(l, "info"));
|
|
}
|
|
}
|
|
|
|
// ── Error field (always live) ─────────────────────────────────────────────
|
|
if (errorText) {
|
|
state.sawErrorLog = true;
|
|
const decodedError = safeDecode(errorText);
|
|
outputMultiline(decodedError, "error");
|
|
if (detectTraceback(decodedError)) {
|
|
state.sawTraceback = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Tick polling
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Sleeps for the given number of milliseconds.
|
|
*
|
|
* @param {number} ms
|
|
* @returns {Promise<void>}
|
|
*/
|
|
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<number>} 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<MonitorResult>}
|
|
*/
|
|
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,
|
|
};
|
|
}
|