6 Commits

Author SHA1 Message Date
Philipp 1aea1ba3b1 chore: update lint workflow to include manual stage hooks
Lint / pre-commit Linting (push) Successful in 42s
Test / Run Tests (push) Successful in 53s
2026-05-16 16:52:17 +02:00
Philipp d674506e49 chore: set github action/workflow checks to manual stage
Lint / pre-commit Linting (push) Failing after 44s
Test / Run Tests (push) Successful in 51s
2026-05-16 16:49:29 +02:00
Philipp 8cf0be7fff chore: exclude no-commit-to-branch from manual stage
Lint / pre-commit Linting (push) Failing after 54s
Test / Run Tests (push) Successful in 1m9s
2026-05-16 16:47:27 +02:00
Philipp e5ceb46096 chore: add stage-distribution hook to ensure dist/ is committed
Lint / pre-commit Linting (push) Failing after 36s
Test / Run Tests (push) Successful in 1m6s
2026-05-16 16:46:06 +02:00
Philipp 28eb7c2971 chore: remove pre-push stage from pre-commit config
Lint / pre-commit Linting (push) Failing after 55s
Test / Run Tests (push) Successful in 1m2s
2026-05-16 16:42:28 +02:00
Philipp 3abc837f78 chore: configure pre-commit stages and add build hook
Lint / pre-commit Linting (push) Failing after 44s
Test / Run Tests (push) Successful in 4m36s
2026-05-16 16:28:07 +02:00
16 changed files with 49 additions and 1544 deletions
-6
View File
@@ -1,6 +0,0 @@
# .gitea/CODEOWNERS
# Gitea's CODEOWNERS uses Go-style Regular Expressions.
# Patterns are evaluated from top to bottom; the last matching rule takes precedence.
# Global owner: Assign @AutoReview to all files
.* @AutoReview
+1 -1
View File
@@ -14,5 +14,5 @@ jobs:
- run: pip install pre-commit - run: pip install pre-commit
shell: bash shell: bash
- name: Pre Commit - name: Pre Commit
run: SKIP=no-commit-to-branch pre-commit run -a run: SKIP=no-commit-to-branch pre-commit run -a --hook-stage pre-commit --hook-stage manual
shell: bash shell: bash
-3
View File
@@ -1078,6 +1078,3 @@ FodyWeavers.xsd
/node_modules/.cache/ /node_modules/.cache/
/coverage/ /coverage/
!/dist/ !/dist/
# Local test credentials — never commit
.env.test
+22 -1
View File
@@ -1,3 +1,4 @@
default_stages: [pre-commit, manual]
exclude: ^(dist|node_modules)/ exclude: ^(dist|node_modules)/
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
@@ -15,6 +16,7 @@ repos:
args: [--autofix, --no-sort-keys, --no-ensure-ascii] args: [--autofix, --no-sort-keys, --no-ensure-ascii]
- id: check-merge-conflict - id: check-merge-conflict
- id: no-commit-to-branch - id: no-commit-to-branch
stages: [pre-commit]
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.16.0 rev: v2.16.0
@@ -33,14 +35,33 @@ repos:
hooks: hooks:
- id: check-renovate - id: check-renovate
- id: check-github-actions - id: check-github-actions
stages: [manual]
- id: check-github-workflows - id: check-github-workflows
stages: [manual]
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v6.0.0
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: (.txt$|.ipynb$|README.md$|readme.mde$) exclude: (.txt$|.ipynb$|README.md$)
- id: trailing-whitespace - id: trailing-whitespace
exclude: (.txt$|README.md$) exclude: (.txt$|README.md$)
- id: mixed-line-ending - id: mixed-line-ending
args: [--fix=lf] args: [--fix=lf]
- repo: local
hooks:
- id: build
name: rebuild-distribution
entry: npm run build
language: system
files: ^(index\.js|monitor\.js|package\.json|package-lock\.json)$
pass_filenames: false
stages: [pre-commit]
- id: add-dist
name: stage-distribution
entry: git add dist/
language: system
files: ^(index\.js|monitor\.js|package\.json|package-lock\.json)$
pass_filenames: false
stages: [pre-commit]
-1
View File
@@ -6,7 +6,6 @@ This repository is maintained by Gemini.
* **Test-Driven Development (TDD):** Wherever possible, Test-Driven Development principles should be followed. Write tests before writing the code they are intended to validate. * **Test-Driven Development (TDD):** Wherever possible, Test-Driven Development principles should be followed. Write tests before writing the code they are intended to validate.
* **Pre-commit Hooks:** Ensure that `pre-commit` hooks are installed and active before making any commits. This can be done by running `pre-commit install` in your local repository. * **Pre-commit Hooks:** Ensure that `pre-commit` hooks are installed and active before making any commits. This can be done by running `pre-commit install` in your local repository.
* **Note for Gemini:** Git commits trigger pre-commit hooks, which can take several seconds (or minutes) to complete. Checking the command status for git commit is only appropriate every 120s.
## Repository Comparison ## Repository Comparison
-13
View File
@@ -26,19 +26,6 @@ To use this action, you need to set it up in your workflow .yml file located in
- `pattern`: Glob pattern to match files (default: *.js). - `pattern`: Glob pattern to match files (default: *.js).
- `branch`: Branch in Screeps to which the code will be uploaded (default: default). - `branch`: Branch in Screeps to which the code will be uploaded (default: default).
- `git-replace`: Overwrite "{{gitRef}}", "{{gitHash}}" and "{{deployTime}}" values in files matching the pattern. - `git-replace`: Overwrite "{{gitRef}}", "{{gitHash}}" and "{{deployTime}}" values in files matching the pattern.
- `shard`: The Screeps shard to monitor (e.g. `shard0`, `shard1`). Defaults to `shard0` on the official server.
- `monitor`: Number of game ticks to monitor the Screeps console after deploying (0 = disabled, default: 0).
- `log_to_file`: If `true`, buffers stdout to an artifact file instead of streaming live (default: false). Note: Errors and warnings always stream live.
- `on_traceback`: Action on JS traceback detection: `ignore`, `warn`, or `fail` (default: `fail`).
- `on_error_log`: Action on Screeps error-console output: `ignore`, `warn`, or `fail` (default: `warn`).
- `on_warning_log`: Action on `console.warn` output: `ignore`, `warn`, or `fail` (default: `ignore`).
- `monitor_interval`: Print a progress update every N ticks (default: 10).
## Outputs
- `saw_traceback`: `true` if a JS traceback was detected during monitoring.
- `saw_error_log`: `true` if the Screeps error console had output during monitoring.
- `saw_warning_log`: `true` if `console.warn` output was detected during monitoring.
Example Workflow Example Workflow
-149
View File
@@ -1,54 +1,9 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
// Mock @actions/core for all tests in this file
vi.mock("@actions/core", () => ({
getInput: vi.fn(),
getBooleanInput: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
}));
// Mock monitor.js so postCode() integration tests don't open a real socket
vi.mock("../monitor.js", () => ({
monitorConsole: vi.fn().mockResolvedValue({
sawTraceback: false,
sawErrorLog: false,
sawWarningLog: false,
}),
}));
// Mock screeps-api
vi.mock("screeps-api", () => {
const mockApi = {
auth: vi.fn().mockResolvedValue(),
code: {
get: vi.fn().mockResolvedValue({ ok: 1, modules: { main: "old_code" } }),
set: vi.fn().mockResolvedValue({ ok: 1 }),
},
};
// Use a regular function so it can be called with `new`
return {
ScreepsAPI: vi.fn(function () {
return mockApi;
}),
};
});
import * as core from "@actions/core";
import { monitorConsole } from "../monitor.js";
import { import {
validateAuthentication, validateAuthentication,
replacePlaceholders, replacePlaceholders,
readReplaceAndWriteFiles, readReplaceAndWriteFiles,
readFilesIntoDict, readFilesIntoDict,
applyOnAction,
postCode,
} from "../index.js"; } from "../index.js";
import { ScreepsAPI } from "screeps-api";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
@@ -242,107 +197,3 @@ describe("glob functionality", () => {
expect(normalizedFiles.sort()).toEqual(expectedFiles); expect(normalizedFiles.sort()).toEqual(expectedFiles);
}); });
}); });
// ────────────────────────────────────────────────────────────────────────────
// applyOnAction
// ────────────────────────────────────────────────────────────────────────────
describe("applyOnAction", () => {
beforeEach(() => vi.clearAllMocks());
it("'ignore' + true → no core call, returns false", () => {
expect(applyOnAction("ignore", true, "msg")).toBe(false);
expect(core.warning).not.toHaveBeenCalled();
expect(core.setFailed).not.toHaveBeenCalled();
});
it("'warn' + true → core.warning() called with message, returns false", () => {
expect(applyOnAction("warn", true, "boom")).toBe(false);
expect(core.warning).toHaveBeenCalledWith("boom");
expect(core.setFailed).not.toHaveBeenCalled();
});
it("'fail' + true → core.setFailed() called with message, returns true", () => {
expect(applyOnAction("fail", true, "boom")).toBe(true);
expect(core.setFailed).toHaveBeenCalledWith("boom");
expect(core.warning).not.toHaveBeenCalled();
});
it("'fail' + false → no core call, returns false", () => {
expect(applyOnAction("fail", false, "boom")).toBe(false);
expect(core.setFailed).not.toHaveBeenCalled();
});
it("'warn' + false → no core call, returns false", () => {
expect(applyOnAction("warn", false, "msg")).toBe(false);
expect(core.warning).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────────────────
// postCode — monitor wiring
// ────────────────────────────────────────────────────────────────────────────
describe("postCode — monitor wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default core mocks
core.getInput.mockImplementation((name) => {
if (name === "monitor") return "0";
if (name === "token") return "test-token";
if (name === "branch") return "default";
if (name === "on_traceback") return "fail";
return "";
});
core.getBooleanInput.mockImplementation((name) => {
if (name === "rollback_on_failure") return false;
return false;
});
});
it("does not call monitorConsole when monitor=0 (default)", async () => {
// We just run postCode with monitor=0 and verify monitorConsole is not called.
await postCode();
expect(monitorConsole).not.toHaveBeenCalled();
});
it("rolls back to previous code when monitor detects a failure and rollback_on_failure is true", async () => {
// Setup inputs for monitor and rollback
core.getInput.mockImplementation((name) => {
if (name === "monitor") return "10";
if (name === "token") return "test-token";
if (name === "branch") return "default";
if (name === "on_traceback") return "fail";
return "";
});
core.getBooleanInput.mockImplementation((name) => {
if (name === "rollback_on_failure") return true;
return false;
});
// Simulate a failure in monitorConsole
monitorConsole.mockResolvedValueOnce({
sawTraceback: true, // Should trigger "fail" due to on_traceback=fail
sawErrorLog: false,
sawWarningLog: false,
});
await postCode();
// Verify rollback was performed
const mockApiInstance = new ScreepsAPI();
// `code.set` should be called twice:
// 1st time: uploading the new files
// 2nd time: rolling back to oldCode
expect(mockApiInstance.code.set).toHaveBeenCalledTimes(2);
expect(mockApiInstance.code.set).toHaveBeenNthCalledWith(2, "default", {
main: "old_code",
});
// Verify it called core.setFailed due to traceback
expect(core.setFailed).toHaveBeenCalledWith(
"Screeps console: traceback detected",
);
});
});
-633
View File
@@ -1,633 +0,0 @@
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.<anonymous> (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 = ["<font color='orange'>WARN: low energy</font>"];
expect(detectWarning(lines)).toBe(true);
});
it("returns true for a line with yellow font tag", () => {
const lines = ['<font color="yellow">WARN: something</font>'];
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", "<font color='orange'>warn</font>"];
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.<anonymous> (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(["<font color='orange'>low energy</font>"]);
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(["<font color='orange'>warn line</font>"]);
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.<anonymous> (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: ["<font color='orange'>low energy</font>"],
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.<anonymous> (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 <anonymous> (_console1778948572008_0:1:46), <anonymous>: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 },
);
});
});
-38
View File
@@ -40,44 +40,6 @@ inputs:
git-replace: git-replace:
description: Allows for the overwrite of the "{{gitRef}}", "{{gitHash}}" and "{{deployTime}}" values in the file matching the file pattern. The file pattern will be combined with the prefix. description: Allows for the overwrite of the "{{gitRef}}", "{{gitHash}}" and "{{deployTime}}" values in the file matching the file pattern. The file pattern will be combined with the prefix.
required: false required: false
shard:
description: The Screeps shard to monitor (e.g. shard0, shard1). Defaults to shard0 on the official server.
required: false
monitor:
description: Number of game ticks to monitor the Screeps console after deploying (0 = disabled).
required: false
default: '0'
log_to_file:
description: 'Buffer stdout to an artifact file instead of streaming live (default: false). Errors/warnings always stream live.'
required: false
default: 'false'
on_traceback:
description: 'Action on JS traceback detection: ignore, warn, or fail (default: fail).'
required: false
default: fail
on_error_log:
description: 'Action on Screeps error-console output: ignore, warn, or fail (default: warn).'
required: false
default: warn
on_warning_log:
description: 'Action on console.warn output: ignore, warn, or fail (default: ignore).'
required: false
default: ignore
monitor_interval:
description: 'Print a progress update every N ticks when log_to_file=true (default: 10).'
required: false
default: '10'
rollback_on_failure:
description: 'Automatically rollback to previous code if the monitor detects failures. Requires monitor > 0. (default: false)'
required: false
default: 'false'
outputs:
saw_traceback:
description: true if a JS traceback was detected during monitoring.
saw_error_log:
description: true if the Screeps error console had output during monitoring.
saw_warning_log:
description: true if console.warn output was detected during monitoring.
runs: runs:
using: node20 using: node20
main: dist/index.js main: dist/index.js
+5 -15
View File
File diff suppressed because one or more lines are too long
+16 -130
View File
@@ -4,7 +4,6 @@ import fs from "fs";
import { glob } from "glob"; import { glob } from "glob";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { monitorConsole } from "./monitor.js";
/** /**
* Replaces specific placeholder strings within the provided content with corresponding dynamic values. * Replaces specific placeholder strings within the provided content with corresponding dynamic values.
@@ -40,8 +39,7 @@ export function replacePlaceholders(content, hostname) {
* @returns {Promise<string[]>} A promise that resolves with an array of file paths that were processed, or rejects with an error if the process fails. * @returns {Promise<string[]>} A promise that resolves with an array of file paths that were processed, or rejects with an error if the process fails.
*/ */
export async function readReplaceAndWriteFiles(pattern, prefix, hostname) { export async function readReplaceAndWriteFiles(pattern, prefix, hostname) {
let globPattern = prefix ? path.join(prefix, pattern) : pattern; const globPattern = prefix ? path.join(prefix, pattern) : pattern;
globPattern = globPattern.replace(/\\/g, "/");
const files = await glob(globPattern); const files = await glob(globPattern);
let processPromises = files.map((file) => { let processPromises = files.map((file) => {
@@ -63,8 +61,7 @@ export async function readReplaceAndWriteFiles(pattern, prefix, hostname) {
*/ */
export async function readFilesIntoDict(pattern, prefix) { export async function readFilesIntoDict(pattern, prefix) {
// Prepend the prefix to the glob pattern // Prepend the prefix to the glob pattern
let globPattern = prefix ? path.join(prefix, pattern) : pattern; const globPattern = prefix ? path.join(prefix, pattern) : pattern;
globPattern = globPattern.replace(/\\/g, "/");
const files = await glob(globPattern); const files = await glob(globPattern);
let fileDict = {}; let fileDict = {};
@@ -111,28 +108,6 @@ export function validateAuthentication(token, username, password) {
return null; // No errors found return null; // No errors found
} }
/**
* Applies the 'ignore' | 'warn' | 'fail' enum action when the given flag is true.
* Exported so it can be unit-tested independently.
*
* @param {'ignore'|'warn'|'fail'} action
* @param {boolean} flag - Only acts when true
* @param {string} message - Passed to core.warning / core.setFailed
* @returns {boolean} - Returns true if the action was 'fail' and the flag was true.
*/
export function applyOnAction(action, flag, message) {
if (!flag) return false;
if (action === "warn") {
core.warning(message);
return false;
}
if (action === "fail") {
core.setFailed(message);
return true;
}
return false;
}
/** /**
* Posts code to Screeps server. * Posts code to Screeps server.
*/ */
@@ -181,112 +156,23 @@ export async function postCode() {
return; return;
} }
let api = new ScreepsAPI(login_arguments); let api = new ScreepsAPI(login_arguments);
if (token) {
if (!token) {
core.info(`Logging in as user ${username}`);
try {
await api.auth(username, password, login_arguments);
} catch (err) {
core.error(`Authentication error: ${err}`);
throw err;
}
}
let oldCode = null;
let rollbackOnFailure = false;
try {
rollbackOnFailure = core.getBooleanInput("rollback_on_failure");
} catch (e) {
// getBooleanInput throws if not 'true' or 'false', ignore
}
if (rollbackOnFailure) {
core.info(
`Downloading existing code from branch ${branch} for potential rollback...`,
);
try {
const getResponse = await api.code.get(branch);
if (getResponse && getResponse.ok && getResponse.modules) {
oldCode = getResponse.modules;
core.info(
`Successfully downloaded existing code (modules: ${Object.keys(oldCode).join(", ")})`,
);
} else {
core.setFailed(
`Failed to download existing code, but rollback_on_failure is enabled. Aborting deployment.`,
);
return;
}
} catch (err) {
core.setFailed(
`Error downloading existing code: ${err.message}. Aborting deployment.`,
);
return;
}
}
try {
const response = await api.code.set(branch, files_to_push); const response = await api.code.set(branch, files_to_push);
core.info(JSON.stringify(response, null, 2)); core.info(JSON.stringify(response, null, 2));
core.info(`Code set successfully to ${branch}`); console.log(`Code set successfully to ${branch}`);
} catch (err) { } else {
core.error(`Upload error: ${err}`); core.info(`Logging in as user ${username}`);
throw err; await Promise.resolve()
} .then(() => api.auth(username, password, login_arguments))
.then(() => {
// Console monitoring (optional) api.code.set(branch, files_to_push);
const monitorTicks = parseInt(core.getInput("monitor") || "0", 10); })
if (monitorTicks > 0) { .then(() => {
const onTraceback = core.getInput("on_traceback") || "fail"; console.log(`Code set successfully to ${branch}`);
const onErrorLog = core.getInput("on_error_log") || "warn"; })
const onWarningLog = core.getInput("on_warning_log") || "ignore"; .catch((err) => {
console.error("Error:", err);
const result = await monitorConsole(api, {
monitor: monitorTicks,
logToFile: core.getBooleanInput("log_to_file"),
onTraceback,
onErrorLog,
onWarningLog,
monitorInterval: parseInt(core.getInput("monitor_interval") || "10", 10),
hostname,
shard: core.getInput("shard") || undefined,
}); });
core.setOutput("saw_traceback", String(result.sawTraceback));
core.setOutput("saw_error_log", String(result.sawErrorLog));
core.setOutput("saw_warning_log", String(result.sawWarningLog));
const fail1 = applyOnAction(
onTraceback,
result.sawTraceback,
"Screeps console: traceback detected",
);
const fail2 = applyOnAction(
onErrorLog,
result.sawErrorLog,
"Screeps console: error log output detected",
);
const fail3 = applyOnAction(
onWarningLog,
result.sawWarningLog,
"Screeps console: warning log output detected",
);
const shouldFail = fail1 || fail2 || fail3;
if (shouldFail && rollbackOnFailure && oldCode) {
core.info(
"Action failed based on monitor configuration. Rolling back to previous code...",
);
try {
await api.code.set(branch, oldCode);
core.info(
`Successfully rolled back to previous code on branch ${branch}.`,
);
} catch (err) {
core.error(`Rollback failed: ${err}`);
}
}
} }
} }
-420
View File
@@ -1,420 +0,0 @@
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:<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) {
// 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 <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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/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<void>}
*/
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<void>}
*/
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 <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, shard?: string } }} event
* @param {{ logToFile: boolean, shard?: string }} opts
* @param {Record<string, string[]>} 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 = /<font\s+color=['"](?:orange|yellow)['"]/i;
logLines
.filter((l) => 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<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;
}
/**
* @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 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,
};
}
+2 -89
View File
@@ -1,14 +1,13 @@
{ {
"name": "screeps-deploy-action", "name": "screeps-deploy-action",
"version": "0.2.1", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "screeps-deploy-action", "name": "screeps-deploy-action",
"version": "0.2.1", "version": "0.1.1",
"dependencies": { "dependencies": {
"@actions/artifact": "^1.1.2",
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"screeps-api": "^1.7.2" "screeps-api": "^1.7.2"
@@ -19,65 +18,6 @@
"vitest": "^4.0.16" "vitest": "^4.0.16"
} }
}, },
"node_modules/@actions/artifact": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/artifact/-/artifact-1.1.3.tgz",
"integrity": "sha512-upUEBnt+yuKKprr+K0LyigDyNJMNdXdWZaID6oGrmftaWJfG0TdKwj2BEHuUpn3J2teNpOTvvy7AGNtw+sykCQ==",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.9.1",
"@actions/http-client": "^2.0.1",
"tmp": "^0.2.1",
"tmp-promise": "^3.0.2"
}
},
"node_modules/@actions/artifact/node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"license": "MIT",
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
}
},
"node_modules/@actions/artifact/node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"license": "MIT",
"dependencies": {
"@actions/io": "^1.0.1"
}
},
"node_modules/@actions/artifact/node_modules/@actions/http-client": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
"integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^5.25.4"
}
},
"node_modules/@actions/artifact/node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@actions/artifact/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/@actions/core": { "node_modules/@actions/core": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz",
@@ -207,15 +147,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
@@ -2005,24 +1936,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmp-promise": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz",
"integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==",
"license": "MIT",
"dependencies": {
"tmp": "^0.2.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+2 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "screeps-deploy-action", "name": "screeps-deploy-action",
"version": "0.2.1", "version": "0.1.1",
"description": "Deploys screeps code to the official game or a private server.", "description": "Deploys screeps code to the official game or an pirvate server.",
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -10,7 +10,6 @@
"build": "ncc build index.js -o dist -m --external utf-8-validate --external bufferutil" "build": "ncc build index.js -o dist -m --external utf-8-validate --external bufferutil"
}, },
"dependencies": { "dependencies": {
"@actions/artifact": "^1.1.2",
"@actions/core": "^3.0.0", "@actions/core": "^3.0.0",
"glob": "^13.0.0", "glob": "^13.0.0",
"screeps-api": "^1.7.2" "screeps-api": "^1.7.2"
-29
View File
@@ -1,29 +0,0 @@
import { glob } from "glob";
import path from "path";
import os from "os";
import fs from "fs";
async function test() {
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "glob-test-"),
);
const file = path.join(tempDir, "test.js");
await fs.promises.writeFile(file, "test");
const pattern = "*.js";
const globPattern = path.join(tempDir, pattern);
console.log("globPattern:", globPattern);
const files = await glob(globPattern);
console.log("found files:", files);
// Fix for windows
const fixedPattern = globPattern.replace(/\\/g, "/");
console.log("fixedPattern:", fixedPattern);
const fixedFiles = await glob(fixedPattern);
console.log("found fixed files:", fixedFiles);
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
test();
-12
View File
@@ -1,12 +0,0 @@
function detectTraceback(errorText) {
if (!errorText) return false;
return /^\s{4}at /m.test(errorText);
}
const encodedTraceback =
"Error: ReferenceError: a is not defined%0A at eval (eval at <anonymous> (_console1778948572008_0:1:46), <anonymous>:1:1)%0A at _console1778948572008_0:1:46%0A at _console1778948572008_0:1:60%0A at exports.evalCode (<runtime>:15347:63)%0A at exports.run (<runtime>:20876:41)%0A";
console.log("Encoded match:", detectTraceback(encodedTraceback));
const decodedTraceback = decodeURIComponent(encodedTraceback);
console.log("Decoded match:", detectTraceback(decodedTraceback));