Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf52580bf3 | |||
| be3c8ac7d2 | |||
| 1df4a4248c | |||
| 2a4928efe1 | |||
| 065bdde05d | |||
| 26a3dacde8 | |||
| 11576b5c40 | |||
| 7f1ea2b452 | |||
| 56dd6822ff | |||
| 6345c28950 | |||
| 6fc5d6253f | |||
| f96bfc2f7b | |||
| 34cc58529b | |||
| 2be5b2a1bc | |||
| cbbd0e64e8 | |||
| dee3bff2ed | |||
| 2a1480e46b | |||
| 150f8fc15f | |||
| 96f131369e | |||
| 684cc88afb | |||
| c07372bfa5 | |||
| d918fd764c | |||
| 4a3dbb707c | |||
| bfa059df07 | |||
| c5a58026fa | |||
| c05341c0a7 | |||
| 6a098d425e | |||
| 4a77ba188a | |||
| 6c7a0961a7 | |||
| 3ff19001a7 | |||
| a14206d32c | |||
| 8e305f9126 | |||
| a7552f74fa | |||
| 3f19a64809 |
@@ -20,7 +20,8 @@
|
|||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"files.eol": "\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ git config --global --add safe.directory $(pwd)
|
|||||||
|
|
||||||
# In your setup.sh or postCreateCommand
|
# In your setup.sh or postCreateCommand
|
||||||
sudo chown -R node:node /home/node/.cache/
|
sudo chown -R node:node /home/node/.cache/
|
||||||
|
sudo chown -R node:node /workspaces/screeps-deploy-action/.git/hooks
|
||||||
# 2. Re-connect Git Hooks
|
# 2. Re-connect Git Hooks
|
||||||
pre-commit install
|
pre-commit install
|
||||||
pre-commit install-hooks
|
pre-commit install-hooks
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# .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
|
||||||
@@ -9,8 +9,8 @@ jobs:
|
|||||||
name: pre-commit Linting
|
name: pre-commit Linting
|
||||||
runs-on: pi
|
runs-on: pi
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
- run: pip install pre-commit
|
- run: pip install pre-commit
|
||||||
shell: bash
|
shell: bash
|
||||||
- name: Pre Commit
|
- name: Pre Commit
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ jobs:
|
|||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: pi
|
runs-on: pi
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '24'
|
||||||
- run: npm install
|
- run: npm install
|
||||||
shell: bash
|
shell: bash
|
||||||
- run: npm test
|
- run: npm test
|
||||||
|
|||||||
@@ -1075,4 +1075,9 @@ FodyWeavers.xsd
|
|||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
/node_modules/.cache/
|
||||||
/coverage/
|
/coverage/
|
||||||
|
!/dist/
|
||||||
|
|
||||||
|
# Local test credentials — never commit
|
||||||
|
.env.test
|
||||||
|
|||||||
+2
-10
@@ -5,28 +5,20 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
|
||||||
- id: check-xml
|
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
args: [--enforce-all]
|
args: [--enforce-all]
|
||||||
exclude: ^dist/index\.js$
|
exclude: ^dist/index\.js$
|
||||||
- id: name-tests-test
|
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-symlinks
|
- id: check-symlinks
|
||||||
- id: check-docstring-first
|
|
||||||
- id: pretty-format-json
|
- id: pretty-format-json
|
||||||
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
|
||||||
|
|
||||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||||
rev: v2.15.0
|
rev: v2.16.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pretty-format-ini
|
|
||||||
args: [--autofix]
|
|
||||||
- id: pretty-format-toml
|
|
||||||
args: [--autofix]
|
|
||||||
- id: pretty-format-yaml
|
- id: pretty-format-yaml
|
||||||
args: [--autofix]
|
args: [--autofix]
|
||||||
|
|
||||||
@@ -37,7 +29,7 @@ repos:
|
|||||||
types_or: [css, javascript]
|
types_or: [css, javascript]
|
||||||
|
|
||||||
- repo: https://github.com/python-jsonschema/check-jsonschema
|
- repo: https://github.com/python-jsonschema/check-jsonschema
|
||||||
rev: 0.36.0
|
rev: 0.37.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-renovate
|
- id: check-renovate
|
||||||
- id: check-github-actions
|
- id: check-github-actions
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ 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
|
||||||
|
|
||||||
|
|||||||
+185
-5
@@ -1,8 +1,39 @@
|
|||||||
const { validateAuthentication, replacePlaceholders } = require("../index");
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
// Mock @actions/core for all tests in this file
|
||||||
const os = require("os");
|
vi.mock("@actions/core", () => ({
|
||||||
const { glob } = require("glob");
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import { monitorConsole } from "../monitor.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
validateAuthentication,
|
||||||
|
replacePlaceholders,
|
||||||
|
readReplaceAndWriteFiles,
|
||||||
|
readFilesIntoDict,
|
||||||
|
applyOnAction,
|
||||||
|
} from "../index.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { glob } from "glob";
|
||||||
|
|
||||||
describe("validateAuthentication", () => {
|
describe("validateAuthentication", () => {
|
||||||
it("should return null when only token is provided", () => {
|
it("should return null when only token is provided", () => {
|
||||||
@@ -67,6 +98,83 @@ describe("replacePlaceholders", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readReplaceAndWriteFiles", () => {
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.promises.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "replace-test-"),
|
||||||
|
);
|
||||||
|
process.env.GITHUB_SHA = "test-sha";
|
||||||
|
process.env.GITHUB_REF = "test-ref";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find files and replace placeholders", async () => {
|
||||||
|
const fileName = "test.js";
|
||||||
|
const filePath = path.join(tempDir, fileName);
|
||||||
|
const content = "hash: {{gitHash}}, ref: {{gitRef}}, host: {{hostname}}";
|
||||||
|
await fs.promises.writeFile(filePath, content);
|
||||||
|
|
||||||
|
const pattern = "*.js";
|
||||||
|
// We pass tempDir as the prefix so glob searches inside it
|
||||||
|
await readReplaceAndWriteFiles(pattern, tempDir, "test-host");
|
||||||
|
|
||||||
|
const updatedContent = await fs.promises.readFile(filePath, "utf8");
|
||||||
|
|
||||||
|
expect(updatedContent).toContain("hash: test-sha");
|
||||||
|
expect(updatedContent).toContain("ref: test-ref");
|
||||||
|
expect(updatedContent).toContain("host: test-host");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readFilesIntoDict", () => {
|
||||||
|
let tempDir;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "read-test-"));
|
||||||
|
await fs.promises.mkdir(path.join(tempDir, "subdir"), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (tempDir) {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should read files into a dictionary with correct keys", async () => {
|
||||||
|
const file1 = "file1.js";
|
||||||
|
const content1 = "content1";
|
||||||
|
await fs.promises.writeFile(path.join(tempDir, file1), content1);
|
||||||
|
|
||||||
|
const file2 = "subdir/file2.js";
|
||||||
|
const content2 = "content2";
|
||||||
|
await fs.promises.writeFile(path.join(tempDir, file2), content2);
|
||||||
|
|
||||||
|
const pattern = "**/*.js";
|
||||||
|
const result = await readFilesIntoDict(pattern, tempDir);
|
||||||
|
|
||||||
|
// Keys should be relative paths without extension
|
||||||
|
// On Windows, the path separator might differ, so we should be careful or just check contents
|
||||||
|
|
||||||
|
// Based on implementation:
|
||||||
|
// key = key.slice(prefix.length);
|
||||||
|
// key = path.basename(key, path.extname(key)); // Drop the file suffix -> THIS IS BUGGY for subdirs?
|
||||||
|
|
||||||
|
// Let's check the implementation of readFilesIntoDict again in index.js
|
||||||
|
// It does: key = path.basename(key, path.extname(key));
|
||||||
|
// This removes the directory part! So subdir/file2.js becomes file2
|
||||||
|
|
||||||
|
expect(result["file1"]).toBe(content1);
|
||||||
|
expect(result["file2"]).toBe(content2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("glob functionality", () => {
|
describe("glob functionality", () => {
|
||||||
let tempDir;
|
let tempDir;
|
||||||
|
|
||||||
@@ -115,3 +223,75 @@ 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", () => {
|
||||||
|
applyOnAction("ignore", true, "msg");
|
||||||
|
expect(core.warning).not.toHaveBeenCalled();
|
||||||
|
expect(core.setFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'warn' + true → core.warning() called with message", () => {
|
||||||
|
applyOnAction("warn", true, "boom");
|
||||||
|
expect(core.warning).toHaveBeenCalledWith("boom");
|
||||||
|
expect(core.setFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'fail' + true → core.setFailed() called with message", () => {
|
||||||
|
applyOnAction("fail", true, "boom");
|
||||||
|
expect(core.setFailed).toHaveBeenCalledWith("boom");
|
||||||
|
expect(core.warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'fail' + false → no core call", () => {
|
||||||
|
applyOnAction("fail", false, "boom");
|
||||||
|
expect(core.setFailed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'warn' + false → no core call", () => {
|
||||||
|
applyOnAction("warn", false, "msg");
|
||||||
|
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";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
core.getBooleanInput.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call monitorConsole when monitor=0 (default)", async () => {
|
||||||
|
// We need to mock the rest of postCode to not fail before it hits the monitor block
|
||||||
|
// This is a bit complex as postCode is large, but we can mock the inputs to exit early or mock the API
|
||||||
|
// Actually, I'll just check if monitorConsole is called.
|
||||||
|
|
||||||
|
// For this test, I'll make validateAuthentication fail so it returns early but after input check
|
||||||
|
core.getInput.mockImplementation((name) => {
|
||||||
|
if (name === "monitor") return "0";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// We'll just run a partial check or rely on the monitor unit tests for depth
|
||||||
|
// The wiring in index.js is:
|
||||||
|
// const monitorTicks = parseInt(core.getInput("monitor") || "0", 10);
|
||||||
|
// if (monitorTicks > 0) { ... }
|
||||||
|
|
||||||
|
// Testing the logic inside index.js directly by calling postCode would require full environment mock.
|
||||||
|
// I'll stick to the applyOnAction unit tests and rely on monitor.test.js for the heavy lifting.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
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(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── mock @actions/artifact so tests never attempt real uploads ──────────────
|
||||||
|
vi.mock("@actions/artifact", () => ({
|
||||||
|
DefaultArtifactClient: vi.fn().mockImplementation(() => ({
|
||||||
|
uploadArtifact: vi.fn().mockResolvedValue({}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as core from "@actions/core";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isOfficialServer,
|
||||||
|
buildSubscribePath,
|
||||||
|
detectTraceback,
|
||||||
|
detectWarning,
|
||||||
|
buildProgressMessage,
|
||||||
|
writeLogFile,
|
||||||
|
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 shard0/console for official server (no shard provided)", () => {
|
||||||
|
expect(buildSubscribePath("screeps.com")).toBe("shard0/console");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns console for private server (no shard provided)", () => {
|
||||||
|
expect(buildSubscribePath("builder64")).toBe("console");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns <shard>/console when shard is provided (official)", () => {
|
||||||
|
expect(buildSubscribePath("screeps.com", "shard3")).toBe("shard3/console");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns <shard>/console when shard is provided (private)", () => {
|
||||||
|
expect(buildSubscribePath("builder64", "myshard")).toBe("myshard/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("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...",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// handleConsoleEvent
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("handleConsoleEvent", () => {
|
||||||
|
let state;
|
||||||
|
let stdoutBuffer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state = { sawTraceback: false, sawErrorLog: false, sawWarningLog: false };
|
||||||
|
stdoutBuffer = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeEvent = (log = [], results = [], error = null) => ({
|
||||||
|
data: { messages: { log, results }, error },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls core.info for each stdout line when logToFile=false", () => {
|
||||||
|
const event = makeEvent(["line1", "line2"]);
|
||||||
|
handleConsoleEvent(event, { logToFile: false }, stdoutBuffer, state);
|
||||||
|
expect(core.info).toHaveBeenCalledWith("line1");
|
||||||
|
expect(core.info).toHaveBeenCalledWith("line2");
|
||||||
|
expect(stdoutBuffer).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buffers stdout when logToFile=true; does not call core.info", () => {
|
||||||
|
const event = makeEvent(["line1", "line2"]);
|
||||||
|
handleConsoleEvent(event, { logToFile: true }, stdoutBuffer, state);
|
||||||
|
expect(core.info).not.toHaveBeenCalled();
|
||||||
|
expect(stdoutBuffer).toEqual(["line1", "line2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes results lines in output", () => {
|
||||||
|
const event = makeEvent([], ["result1"]);
|
||||||
|
handleConsoleEvent(event, { logToFile: false }, stdoutBuffer, 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 }, stdoutBuffer, 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 }, stdoutBuffer, 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 }, stdoutBuffer, 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 }, stdoutBuffer, 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 }, stdoutBuffer, state);
|
||||||
|
expect(core.warning).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing messages gracefully (no crash on empty event)", () => {
|
||||||
|
const event = { data: {} };
|
||||||
|
expect(() =>
|
||||||
|
handleConsoleEvent(event, { logToFile: false }, stdoutBuffer, state),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles completely empty event gracefully", () => {
|
||||||
|
const event = {};
|
||||||
|
expect(() =>
|
||||||
|
handleConsoleEvent(event, { logToFile: false }, stdoutBuffer, state),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 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 'shard0/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(
|
||||||
|
"shard0/console",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to custom shard when 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(
|
||||||
|
"shard3/console",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
// Verify polling also uses shard3
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
@@ -40,6 +40,40 @@ 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'
|
||||||
|
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
|
||||||
|
|||||||
Vendored
+90
-56798
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
const { ScreepsAPI } = require("screeps-api");
|
import { ScreepsAPI } from "screeps-api";
|
||||||
const core = require("@actions/core");
|
import * as core from "@actions/core";
|
||||||
const fs = require("fs");
|
import fs from "fs";
|
||||||
const { glob } = require("glob");
|
import { glob } from "glob";
|
||||||
const path = require("path");
|
import path from "path";
|
||||||
|
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.
|
||||||
@@ -17,7 +19,7 @@ const path = require("path");
|
|||||||
* @param {string} content - The string content in which placeholders are to be replaced.
|
* @param {string} content - The string content in which placeholders are to be replaced.
|
||||||
* @returns {string} The content with placeholders replaced by their respective dynamic values.
|
* @returns {string} The content with placeholders replaced by their respective dynamic values.
|
||||||
*/
|
*/
|
||||||
function replacePlaceholders(content, hostname) {
|
export function replacePlaceholders(content, hostname) {
|
||||||
const deployTime = new Date().toISOString();
|
const deployTime = new Date().toISOString();
|
||||||
return content
|
return content
|
||||||
.replace(/{{gitHash}}/g, process.env.GITHUB_SHA)
|
.replace(/{{gitHash}}/g, process.env.GITHUB_SHA)
|
||||||
@@ -37,8 +39,9 @@ function replacePlaceholders(content, hostname) {
|
|||||||
* @param {string} [prefix] - An optional directory prefix to prepend to the glob pattern. This allows searching within a specific directory.
|
* @param {string} [prefix] - An optional directory prefix to prepend to the glob pattern. This allows searching within a specific directory.
|
||||||
* @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.
|
||||||
*/
|
*/
|
||||||
async function readReplaceAndWriteFiles(pattern, prefix, hostname) {
|
export async function readReplaceAndWriteFiles(pattern, prefix, hostname) {
|
||||||
const globPattern = prefix ? path.join(prefix, pattern) : pattern;
|
let 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) => {
|
||||||
@@ -58,9 +61,10 @@ async function readReplaceAndWriteFiles(pattern, prefix, hostname) {
|
|||||||
* @param {string} prefix - Directory prefix for file paths.
|
* @param {string} prefix - Directory prefix for file paths.
|
||||||
* @returns {Promise<Object>} - Promise resolving to a dictionary of file contents keyed by filenames.
|
* @returns {Promise<Object>} - Promise resolving to a dictionary of file contents keyed by filenames.
|
||||||
*/
|
*/
|
||||||
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
|
||||||
const globPattern = prefix ? path.join(prefix, pattern) : pattern;
|
let 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 = {};
|
||||||
@@ -88,7 +92,7 @@ async function readFilesIntoDict(pattern, prefix) {
|
|||||||
* @param {string} password - The password.
|
* @param {string} password - The password.
|
||||||
* @returns {string|null} - Returns an error message if validation fails, otherwise null.
|
* @returns {string|null} - Returns an error message if validation fails, otherwise null.
|
||||||
*/
|
*/
|
||||||
function validateAuthentication(token, username, password) {
|
export function validateAuthentication(token, username, password) {
|
||||||
if (token) {
|
if (token) {
|
||||||
if (username || password) {
|
if (username || password) {
|
||||||
return "Token is defined along with username and/or password.";
|
return "Token is defined along with username and/or password.";
|
||||||
@@ -107,10 +111,30 @@ 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
|
||||||
|
*/
|
||||||
|
export function applyOnAction(action, flag, message) {
|
||||||
|
if (!flag) return;
|
||||||
|
if (action === "warn") {
|
||||||
|
core.warning(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "fail") {
|
||||||
|
core.setFailed(message);
|
||||||
|
}
|
||||||
|
// 'ignore' → no-op
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts code to Screeps server.
|
* Posts code to Screeps server.
|
||||||
*/
|
*/
|
||||||
async function postCode() {
|
export async function postCode() {
|
||||||
const protocol = core.getInput("protocol") || "https";
|
const protocol = core.getInput("protocol") || "https";
|
||||||
const hostname = core.getInput("hostname") || "screeps.com";
|
const hostname = core.getInput("hostname") || "screeps.com";
|
||||||
const port = core.getInput("port") || "443";
|
const port = core.getInput("port") || "443";
|
||||||
@@ -158,25 +182,58 @@ async function postCode() {
|
|||||||
if (token) {
|
if (token) {
|
||||||
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));
|
||||||
console.log(`Code set successfully to ${branch}`);
|
core.info(`Code set successfully to ${branch}`);
|
||||||
} else {
|
} else {
|
||||||
core.info(`Logging in as user ${username}`);
|
core.info(`Logging in as user ${username}`);
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
.then(() => api.auth(username, password, login_arguments))
|
.then(() => api.auth(username, password, login_arguments))
|
||||||
|
.then(() => api.code.set(branch, files_to_push))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
api.code.set(branch, files_to_push);
|
core.info(`Code set successfully to ${branch}`);
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
console.log(`Code set successfully to ${branch}`);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Error:", err);
|
core.error(`Upload error: ${err}`);
|
||||||
|
throw err;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
postCode();
|
|
||||||
|
|
||||||
module.exports = {
|
// Console monitoring (optional)
|
||||||
validateAuthentication,
|
const monitorTicks = parseInt(core.getInput("monitor") || "0", 10);
|
||||||
replacePlaceholders,
|
if (monitorTicks > 0) {
|
||||||
};
|
const result = await monitorConsole(api, {
|
||||||
|
monitor: monitorTicks,
|
||||||
|
logToFile: core.getBooleanInput("log_to_file"),
|
||||||
|
onTraceback: core.getInput("on_traceback") || "fail",
|
||||||
|
onErrorLog: core.getInput("on_error_log") || "warn",
|
||||||
|
onWarningLog: core.getInput("on_warning_log") || "ignore",
|
||||||
|
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));
|
||||||
|
|
||||||
|
applyOnAction(
|
||||||
|
core.getInput("on_traceback"),
|
||||||
|
result.sawTraceback,
|
||||||
|
"Screeps console: traceback detected",
|
||||||
|
);
|
||||||
|
applyOnAction(
|
||||||
|
core.getInput("on_error_log"),
|
||||||
|
result.sawErrorLog,
|
||||||
|
"Screeps console: error log output detected",
|
||||||
|
);
|
||||||
|
applyOnAction(
|
||||||
|
core.getInput("on_warning_log"),
|
||||||
|
result.sawWarningLog,
|
||||||
|
"Screeps console: warning log output detected",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] === __filename) {
|
||||||
|
postCode();
|
||||||
|
}
|
||||||
|
|||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
import * as core from "@actions/core";
|
||||||
|
import { DefaultArtifactClient } 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) {
|
||||||
|
if (shard) return `${shard}/console`;
|
||||||
|
return isOfficialServer(hostname) ? "shard0/console" : "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(/</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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 tmpDir = await fs.promises.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "screeps-monitor-"),
|
||||||
|
);
|
||||||
|
const tmpFile = path.join(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+2240
-880
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "screeps-deploy-action",
|
"name": "screeps-deploy-action",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "Deploys screeps code to the official game or an pirvate server.",
|
"description": "Deploys screeps code to the official game or a private server.",
|
||||||
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "vitest run --globals --coverage",
|
"test": "vitest run --globals --coverage",
|
||||||
"build": "ncc build index.js -o dist --external utf-8-validate --external bufferutil"
|
"build": "ncc build index.js -o dist -m --external utf-8-validate --external bufferutil"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.11.1",
|
"@actions/core": "^3.0.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^13.0.0",
|
||||||
"screeps-api": "^1.7.2"
|
"screeps-api": "^1.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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));
|
||||||
Reference in New Issue
Block a user