import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; // ── mock @actions/core so tests never touch real CI outputs ───────────────── vi.mock("@actions/core", () => ({ info: vi.fn(), error: vi.fn(), warning: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), startGroup: vi.fn(), endGroup: vi.fn(), })); import * as artifact from "@actions/artifact"; vi.mock("@actions/artifact", () => { const mockClient = { uploadArtifact: vi.fn().mockResolvedValue({}), }; return { create: vi.fn(() => mockClient), }; }); import * as core from "@actions/core"; import fs from "fs"; import os from "os"; import path from "path"; import { isOfficialServer, buildSubscribePath, detectTraceback, detectWarning, outputMultiline, buildProgressMessage, writeLogFile, uploadLogArtifacts, handleConsoleEvent, monitorConsole, } from "../monitor.js"; // ──────────────────────────────────────────────────────────────────────────── // Pure helpers // ──────────────────────────────────────────────────────────────────────────── describe("isOfficialServer", () => { it("returns true for screeps.com", () => { expect(isOfficialServer("screeps.com")).toBe(true); }); it("returns false for a private hostname", () => { expect(isOfficialServer("builder64")).toBe(false); }); it("returns false for an IP address", () => { expect(isOfficialServer("192.168.1.10")).toBe(false); }); }); describe("buildSubscribePath", () => { it("returns console for official server (no shard provided)", () => { expect(buildSubscribePath("screeps.com")).toBe("console"); }); it("returns console for private server (no shard provided)", () => { expect(buildSubscribePath("builder64")).toBe("console"); }); it("returns console when shard is provided (official)", () => { expect(buildSubscribePath("screeps.com", "shard3")).toBe("console"); }); it("returns console when shard is provided (private)", () => { expect(buildSubscribePath("builder64", "myshard")).toBe("console"); }); }); describe("detectTraceback", () => { it("returns true when error contains a stack frame line", () => { const error = "TypeError: Cannot read properties of undefined\n at Object. (main:1:42)"; expect(detectTraceback(error)).toBe(true); }); it("returns false for a plain error message without stack frames", () => { expect(detectTraceback("Something went wrong")).toBe(false); }); it("returns false for null", () => { expect(detectTraceback(null)).toBe(false); }); it("returns false for undefined", () => { expect(detectTraceback(undefined)).toBe(false); }); it("returns false for an empty string", () => { expect(detectTraceback("")).toBe(false); }); }); describe("detectWarning", () => { it("returns true for a line with orange font tag", () => { const lines = ["WARN: low energy"]; expect(detectWarning(lines)).toBe(true); }); it("returns true for a line with yellow font tag", () => { const lines = ['WARN: something']; expect(detectWarning(lines)).toBe(true); }); it("returns false for a plain log line", () => { expect(detectWarning(["Tick 123: harvesting"])).toBe(false); }); it("returns false for an empty array", () => { expect(detectWarning([])).toBe(false); }); it("returns false for null", () => { expect(detectWarning(null)).toBe(false); }); it("returns true when only one line in a mixed array is a warning", () => { const lines = ["normal line", "warn"]; expect(detectWarning(lines)).toBe(true); }); }); describe("outputMultiline", () => { beforeEach(() => { vi.clearAllMocks(); }); it("prefixes lines with shard when provided", () => { outputMultiline("line1\nline2", "info", "shard0"); expect(core.info).toHaveBeenCalledWith("[shard0] line1"); expect(core.info).toHaveBeenCalledWith("[shard0] line2"); }); it("does not prefix when shard is missing", () => { outputMultiline("line1", "info"); expect(core.info).toHaveBeenCalledWith("line1"); }); it("uses core.warning for level=warning", () => { outputMultiline("warn", "warning", "s0"); expect(core.warning).toHaveBeenCalledWith("[s0] warn"); }); it("uses core.error for level=error", () => { outputMultiline("err", "error", "s0"); expect(core.error).toHaveBeenCalledWith("[s0] err"); }); }); describe("buildProgressMessage", () => { it("formats correctly at 0 elapsed", () => { expect(buildProgressMessage(0, 50)).toBe("[Monitor] 0/50 ticks elapsed..."); }); it("formats correctly midway", () => { expect(buildProgressMessage(25, 50)).toBe( "[Monitor] 25/50 ticks elapsed...", ); }); it("formats correctly at 100%", () => { expect(buildProgressMessage(50, 50)).toBe( "[Monitor] 50/50 ticks elapsed...", ); }); }); describe("uploadLogArtifacts", () => { beforeEach(() => { vi.clearAllMocks(); }); it("instantiates client and calls uploadArtifact", async () => { await uploadLogArtifacts( ["/path/to/shard0_console_log.txt"], "custom-name", ); expect(artifact.create().uploadArtifact).toHaveBeenCalledWith( "custom-name", ["/path/to/shard0_console_log.txt"], "/path/to", { continueOnError: true }, ); }); it("does nothing if filePaths is empty", async () => { await uploadLogArtifacts([]); expect(artifact.create().uploadArtifact).not.toHaveBeenCalled(); }); }); // ──────────────────────────────────────────────────────────────────────────── // handleConsoleEvent // ──────────────────────────────────────────────────────────────────────────── describe("handleConsoleEvent", () => { let state; let shardBuffers; beforeEach(() => { vi.clearAllMocks(); state = { sawTraceback: false, sawErrorLog: false, sawWarningLog: false }; shardBuffers = {}; }); const makeEvent = (log = [], results = [], error = null) => ({ data: { messages: { log, results }, error }, }); it("calls core.info for each stdout line with shard prefix when logToFile=false", () => { const event = { data: { shard: "shard0", messages: { log: ["line1"], results: ["line2"] }, }, }; handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(core.info).toHaveBeenCalledWith("[shard0] line1"); expect(core.info).toHaveBeenCalledWith("[shard0] line2"); expect(Object.keys(shardBuffers)).toHaveLength(0); }); it("buffers stdout when logToFile=true; does not call core.info", () => { const event = { data: { shard: "shard0", messages: { log: ["line1", "line2"] } }, }; handleConsoleEvent(event, { logToFile: true }, shardBuffers, state); expect(core.info).not.toHaveBeenCalled(); expect(shardBuffers["shard0"]).toEqual(["line1", "line2"]); }); it("includes results lines in output", () => { const event = makeEvent([], ["result1"]); handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(core.info).toHaveBeenCalledWith("result1"); }); it("calls core.error when error field is non-empty", () => { const event = makeEvent([], [], "Script crashed"); handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(core.error).toHaveBeenCalledWith("Script crashed"); expect(state.sawErrorLog).toBe(true); }); it("sets state.sawTraceback when error contains stack frames", () => { const error = "TypeError: boom\n at Object. (main:1:1)"; const event = makeEvent([], [], error); handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(state.sawTraceback).toBe(true); expect(state.sawErrorLog).toBe(true); }); it("does not set sawTraceback for plain error without stack frames", () => { const event = makeEvent([], [], "Script error: low CPU"); handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(state.sawTraceback).toBe(false); expect(state.sawErrorLog).toBe(true); }); it("sets state.sawWarningLog and calls core.warning for warn lines", () => { const event = makeEvent(["low energy"]); handleConsoleEvent(event, { logToFile: false }, shardBuffers, state); expect(state.sawWarningLog).toBe(true); expect(core.warning).toHaveBeenCalled(); }); it("calls core.warning regardless of logToFile setting", () => { const event = makeEvent(["warn line"]); handleConsoleEvent(event, { logToFile: true }, shardBuffers, state); expect(core.warning).toHaveBeenCalled(); }); it("handles missing messages gracefully (no crash on empty event)", () => { const event = { data: {} }; expect(() => handleConsoleEvent(event, { logToFile: false }, shardBuffers, state), ).not.toThrow(); }); it("handles completely empty event gracefully", () => { const event = {}; expect(() => handleConsoleEvent(event, { logToFile: false }, shardBuffers, state), ).not.toThrow(); }); it("filters messages by shard when targetShard is provided", () => { const event = { data: { shard: "shard0", messages: { log: ["msg0"] } } }; handleConsoleEvent(event, { shard: "shard1" }, shardBuffers, state); expect(core.info).not.toHaveBeenCalled(); handleConsoleEvent(event, { shard: "shard0" }, shardBuffers, state); expect(core.info).toHaveBeenCalledWith("[shard0] msg0"); }); it("does not filter when targetShard is undefined", () => { const event = { data: { shard: "shard0", messages: { log: ["msg0"] } } }; handleConsoleEvent(event, {}, shardBuffers, state); expect(core.info).toHaveBeenCalledWith("[shard0] msg0"); }); it("buffers messages separately for different shards", () => { const event0 = { data: { shard: "shard0", messages: { log: ["msg0"] } } }; const event1 = { data: { shard: "shard1", messages: { log: ["msg1"] } } }; handleConsoleEvent(event0, { logToFile: true }, shardBuffers, state); handleConsoleEvent(event1, { logToFile: true }, shardBuffers, state); expect(shardBuffers["shard0"]).toEqual(["msg0"]); expect(shardBuffers["shard1"]).toEqual(["msg1"]); }); it("uses 'default' key when shard is missing in event", () => { const event = { data: { messages: { log: ["msg"] } } }; handleConsoleEvent(event, { logToFile: true }, shardBuffers, state); expect(shardBuffers["default"]).toEqual(["msg"]); }); }); // ──────────────────────────────────────────────────────────────────────────── // writeLogFile // ──────────────────────────────────────────────────────────────────────────── describe("writeLogFile", () => { let tempDir; let tempFile; beforeEach(async () => { tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), "monitor-test-"), ); tempFile = path.join(tempDir, "log.txt"); }); afterEach(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); }); it("writes all lines to file joined by newlines", async () => { await writeLogFile(["line1", "line2", "line3"], tempFile); const content = await fs.promises.readFile(tempFile, "utf8"); expect(content).toBe("line1\nline2\nline3"); }); it("writes an empty file when given an empty array", async () => { await writeLogFile([], tempFile); const content = await fs.promises.readFile(tempFile, "utf8"); expect(content).toBe(""); }); }); // ──────────────────────────────────────────────────────────────────────────── // monitorConsole — integration with mocked API // ──────────────────────────────────────────────────────────────────────────── /** * Builds a mock ScreepsAPI that: * - api.time(shard) returns ticks from a list on each call * - api.socket.connect() → resolves immediately * - api.socket.subscribe() → resolves immediately (stores the callback) * - api.socket.disconnect()→ no-op */ function buildMockApi({ ticks = [100, 101, 102, 103, 104, 105], hostname = "builder64", } = {}) { let tickIndex = 0; let consoleCallback = null; const socket = { connect: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn().mockImplementation((_path, cb) => { consoleCallback = cb; return Promise.resolve(); }), disconnect: vi.fn(), }; const api = { opts: { hostname }, time: vi.fn().mockImplementation(() => { const t = ticks[Math.min(tickIndex, ticks.length - 1)]; tickIndex++; return Promise.resolve({ time: t }); }), socket, // Expose so tests can fire console events _fireConsole: (eventData) => { if (consoleCallback) { consoleCallback({ data: eventData }); } }, }; return api; } const BASE_OPTS = { monitor: 3, logToFile: false, onTraceback: "fail", onErrorLog: "warn", onWarningLog: "ignore", monitorInterval: 2, hostname: "builder64", }; describe("monitorConsole", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns all three flags false on a clean run", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); const result = await monitorConsole(api, BASE_OPTS); expect(result).toEqual({ sawTraceback: false, sawErrorLog: false, sawWarningLog: false, }); }); it("calls api.socket.connect() exactly once", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); await monitorConsole(api, BASE_OPTS); expect(api.socket.connect).toHaveBeenCalledTimes(1); }); it("calls api.socket.disconnect() after completion", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); await monitorConsole(api, BASE_OPTS); expect(api.socket.disconnect).toHaveBeenCalledTimes(1); }); it("subscribes to 'console' for a private server", async () => { const api = buildMockApi({ hostname: "builder64", ticks: [100, 101, 102, 103], }); await monitorConsole(api, { ...BASE_OPTS, hostname: "builder64" }); expect(api.socket.subscribe).toHaveBeenCalledWith( "console", expect.any(Function), ); }); it("subscribes to 'console' for the official server (default)", async () => { const api = buildMockApi({ hostname: "screeps.com", ticks: [100, 101, 102, 103], }); await monitorConsole(api, { ...BASE_OPTS, hostname: "screeps.com" }); expect(api.socket.subscribe).toHaveBeenCalledWith( "console", expect.any(Function), ); }); it("subscribes to 'console' even when custom shard is provided", async () => { const api = buildMockApi({ hostname: "screeps.com", ticks: [100, 101, 102, 103], }); await monitorConsole(api, { ...BASE_OPTS, hostname: "screeps.com", shard: "shard3", }); expect(api.socket.subscribe).toHaveBeenCalledWith( "console", expect.any(Function), ); // Verify polling still uses shard3 for timing expect(api.time).toHaveBeenCalledWith("shard3"); }); it("returns sawTraceback=true when a traceback event arrives", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); // Schedule console event before poll advances setTimeout(() => { api._fireConsole({ messages: { log: [], results: [] }, error: "TypeError: boom\n at Object. (main:1:1)", }); }, 0); const result = await monitorConsole(api, BASE_OPTS); expect(result.sawTraceback).toBe(true); }); it("returns sawErrorLog=true when an error (no traceback) event arrives", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); setTimeout(() => { api._fireConsole({ messages: { log: [], results: [] }, error: "Script error: CPU limit exceeded", }); }, 0); const result = await monitorConsole(api, BASE_OPTS); expect(result.sawErrorLog).toBe(true); expect(result.sawTraceback).toBe(false); }); it("returns sawWarningLog=true when a warn log line arrives", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); setTimeout(() => { api._fireConsole({ messages: { log: ["low energy"], results: [], }, error: null, }); }, 0); const result = await monitorConsole(api, BASE_OPTS); expect(result.sawWarningLog).toBe(true); }); it("logs progress via core.info at monitorInterval boundaries when logToFile=true", async () => { // ticks: start=100, then 101,102(interval),103(done at delta=3) const api = buildMockApi({ ticks: [100, 100, 101, 102, 103] }); await monitorConsole(api, { ...BASE_OPTS, logToFile: true, monitorInterval: 2, }); // Progress should be logged when elapsed reaches 2 const infoCalls = core.info.mock.calls.map((c) => c[0]); expect(infoCalls.some((m) => m.includes("2/3"))).toBe(true); }); it("calls api.socket.disconnect() even if an error occurs during polling", async () => { const api = buildMockApi({ ticks: [100] }); // Make time() reject after first call let calls = 0; api.time = vi.fn().mockImplementation(() => { calls++; if (calls > 1) return Promise.reject(new Error("network error")); return Promise.resolve({ time: 100 }); }); await expect(monitorConsole(api, BASE_OPTS)).rejects.toThrow( "network error", ); expect(api.socket.disconnect).toHaveBeenCalledTimes(1); }); it("exits early if a traceback occurs and onTraceback='fail'", async () => { // startTick=100, monitor=10 ticks. // If it didn't exit early, it would call api.time() many times. const api = buildMockApi({ ticks: [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110], }); // Fire a traceback event after the first poll setTimeout(() => { api._fireConsole({ messages: { log: [], results: [] }, error: "TypeError: fail-fast test\n at Object. (main:1:1)", }); }, 100); const result = await monitorConsole(api, { ...BASE_OPTS, monitor: 10, onTraceback: "fail", }); expect(result.sawTraceback).toBe(true); // We expect it to have called api.time fewer than 10 times (excluding the startTick call) expect(api.time.mock.calls.length).toBeLessThan(10); }); it("detects encoded tracebacks in log lines", async () => { const api = buildMockApi({ ticks: [100, 101, 102] }); setTimeout(() => { api._fireConsole({ messages: { log: [ "Error: ReferenceError: a is not defined%0A at eval (eval at (_console1778948572008_0:1:46), :1:1)%0A at _console1778948572008_0:1:46", ], results: [], }, error: null, }); }, 50); const result = await monitorConsole(api, { ...BASE_OPTS, onTraceback: "fail", }); expect(result.sawTraceback).toBe(true); expect(result.sawErrorLog).toBe(true); }); it("creates separate log files for different shards when logToFile=true", async () => { const api = buildMockApi({ ticks: [100, 101, 102, 103] }); setTimeout(() => { api._fireConsole({ shard: "shard0", messages: { log: ["msg0"], results: [] }, error: null, }); api._fireConsole({ shard: "shard1", messages: { log: ["msg1"], results: [] }, error: null, }); }, 50); // Verify uploadArtifact was called with two files await monitorConsole(api, { ...BASE_OPTS, logToFile: true, }); expect(artifact.create().uploadArtifact).toHaveBeenCalledWith( "screeps-console-log", expect.arrayContaining([ expect.stringContaining("shard0_console_log.txt"), expect.stringContaining("shard1_console_log.txt"), ]), expect.any(String), { continueOnError: true }, ); }); });