d0a08da728
This PR adds the ability to download existing code before deployment and automatically roll back if the post-deployment monitor detects a failure. Reviewed-on: #88 Reviewed-by: LLMReview <27+autoreview@noreplay.horstenkamp.eu>
349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
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 {
|
|
validateAuthentication,
|
|
replacePlaceholders,
|
|
readReplaceAndWriteFiles,
|
|
readFilesIntoDict,
|
|
applyOnAction,
|
|
postCode,
|
|
} from "../index.js";
|
|
import { ScreepsAPI } from "screeps-api";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import os from "os";
|
|
import { glob } from "glob";
|
|
|
|
describe("validateAuthentication", () => {
|
|
it("should return null when only token is provided", () => {
|
|
expect(validateAuthentication("token", null, null)).toBeNull();
|
|
});
|
|
|
|
it("should return an error message when token and username are provided", () => {
|
|
expect(validateAuthentication("token", "user", null)).toBe(
|
|
"Token is defined along with username and/or password.",
|
|
);
|
|
});
|
|
|
|
it("should return an error message when token and password are provided", () => {
|
|
expect(validateAuthentication("token", null, "pass")).toBe(
|
|
"Token is defined along with username and/or password.",
|
|
);
|
|
});
|
|
|
|
it("should return an error message when token, username, and password are provided", () => {
|
|
expect(validateAuthentication("token", "user", "pass")).toBe(
|
|
"Token is defined along with username and/or password.",
|
|
);
|
|
});
|
|
|
|
it("should return an error message when no credentials are provided", () => {
|
|
expect(validateAuthentication(null, null, null)).toBe(
|
|
"Neither token nor password and username are defined.",
|
|
);
|
|
});
|
|
|
|
it("should return an error message when only username is provided", () => {
|
|
expect(validateAuthentication(null, "user", null)).toBe(
|
|
"Username is defined but no password is provided.",
|
|
);
|
|
});
|
|
|
|
it("should return an error message when only password is provided", () => {
|
|
expect(validateAuthentication(null, null, "pass")).toBe(
|
|
"Password is defined but no username is provided.",
|
|
);
|
|
});
|
|
|
|
it("should return null when username and password are provided", () => {
|
|
expect(validateAuthentication(null, "user", "pass")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("replacePlaceholders", () => {
|
|
beforeEach(() => {
|
|
process.env.GITHUB_SHA = "test-sha";
|
|
process.env.GITHUB_REF = "test-ref";
|
|
});
|
|
|
|
it("should replace all placeholders", () => {
|
|
const content =
|
|
"hash: {{gitHash}}, ref: {{gitRef}}, time: {{deployTime}}, host: {{hostname}}";
|
|
const replacedContent = replacePlaceholders(content, "test-host");
|
|
expect(replacedContent).toMatch(/hash: test-sha/);
|
|
expect(replacedContent).toMatch(/ref: test-ref/);
|
|
expect(replacedContent).toMatch(/time: .*/);
|
|
expect(replacedContent).toMatch(/host: test-host/);
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
let tempDir;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "glob-test-"));
|
|
await fs.promises.mkdir(path.join(tempDir, "lib"), { recursive: true });
|
|
await fs.promises.mkdir(path.join(tempDir, "deep", "folder"), {
|
|
recursive: true,
|
|
});
|
|
await fs.promises.writeFile(path.join(tempDir, "main.js"), "content");
|
|
await fs.promises.writeFile(path.join(tempDir, "utils.js"), "content");
|
|
await fs.promises.writeFile(
|
|
path.join(tempDir, "lib", "helper.js"),
|
|
"content",
|
|
);
|
|
await fs.promises.writeFile(
|
|
path.join(tempDir, "lib", "data.json"),
|
|
"content",
|
|
);
|
|
await fs.promises.writeFile(
|
|
path.join(tempDir, "deep", "folder", "main.js"),
|
|
"content",
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("should find all javascript files in the directory", async () => {
|
|
// Ensure pattern uses forward slashes for glob
|
|
const pattern = path.join(tempDir, "**", "*.js").split(path.sep).join("/");
|
|
const files = await glob(pattern);
|
|
|
|
// Normalize file paths to system separator (backslashes on Windows)
|
|
const normalizedFiles = files.map((f) => path.normalize(f));
|
|
|
|
const expectedFiles = [
|
|
path.join(tempDir, "deep", "folder", "main.js"),
|
|
path.join(tempDir, "lib", "helper.js"),
|
|
path.join(tempDir, "main.js"),
|
|
path.join(tempDir, "utils.js"),
|
|
].sort();
|
|
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",
|
|
);
|
|
});
|
|
});
|