feat: Overhaul Docker infrastructure with multi-stage builds, add a cleanup script, and refactor the update service to combine update and requirement checks.
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
||||
import * as fs from "fs/promises";
|
||||
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
||||
|
||||
// Mock child_process BEFORE importing the service
|
||||
const mockExec = mock((cmd: string, callback?: any) => {
|
||||
@@ -8,23 +7,32 @@ const mockExec = mock((cmd: string, callback?: any) => {
|
||||
return { unref: () => { } };
|
||||
}
|
||||
|
||||
if (cmd.includes("git rev-parse")) {
|
||||
callback(null, { stdout: "main\n" });
|
||||
// Simulate successful command execution
|
||||
let stdout = "";
|
||||
|
||||
if (cmd.includes("git rev-parse --abbrev-ref")) {
|
||||
stdout = "main\n";
|
||||
} else if (cmd.includes("git rev-parse --short")) {
|
||||
stdout = "abc1234\n";
|
||||
} else if (cmd.includes("git rev-parse HEAD")) {
|
||||
stdout = "abc1234567890\n";
|
||||
} else if (cmd.includes("git fetch")) {
|
||||
callback(null, { stdout: "" });
|
||||
stdout = "";
|
||||
} else if (cmd.includes("git log")) {
|
||||
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||
stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2";
|
||||
} else if (cmd.includes("git diff")) {
|
||||
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
||||
stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts";
|
||||
} else if (cmd.includes("git reset")) {
|
||||
callback(null, { stdout: "HEAD is now at abcdef Update 1" });
|
||||
stdout = "HEAD is now at abcdef Update 1";
|
||||
} else if (cmd.includes("bun install")) {
|
||||
callback(null, { stdout: "Installed dependencies" });
|
||||
stdout = "Installed dependencies";
|
||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||
callback(null, { stdout: "Migrations applied" });
|
||||
} else {
|
||||
callback(null, { stdout: "" });
|
||||
stdout = "Migrations applied";
|
||||
} else if (cmd.includes("bun run build")) {
|
||||
stdout = "Build completed";
|
||||
}
|
||||
|
||||
callback(null, stdout, "");
|
||||
});
|
||||
|
||||
mock.module("child_process", () => ({
|
||||
@@ -32,9 +40,9 @@ mock.module("child_process", () => ({
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((path: string) => Promise.resolve());
|
||||
const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((_path: string) => Promise.resolve());
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
@@ -43,9 +51,9 @@ mock.module("fs/promises", () => ({
|
||||
}));
|
||||
|
||||
// Mock view module to avoid import issues
|
||||
mock.module("./update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
||||
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
||||
mock.module("@/modules/admin/update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }),
|
||||
getPostRestartProgressEmbed: () => ({ title: "Progress..." }),
|
||||
}));
|
||||
|
||||
describe("UpdateService", () => {
|
||||
@@ -72,7 +80,8 @@ describe("UpdateService", () => {
|
||||
|
||||
expect(result.hasUpdates).toBe(true);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.log).toContain("Update 1");
|
||||
expect(result.commits.length).toBeGreaterThan(0);
|
||||
expect(result.commits[0].message).toContain("Update 1");
|
||||
});
|
||||
|
||||
test("should call git rev-parse, fetch, and log commands", async () => {
|
||||
@@ -83,43 +92,82 @@ describe("UpdateService", () => {
|
||||
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should include requirements in the response", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.requirements).toBeDefined();
|
||||
expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock
|
||||
expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock
|
||||
expect(result.requirements.changedFiles).toContain("package.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("performUpdate", () => {
|
||||
test("should run git reset --hard with correct branch", async () => {
|
||||
await UpdateService.performUpdate("main");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateRequirements", () => {
|
||||
describe("checkUpdateRequirements (deprecated)", () => {
|
||||
test("should detect package.json and schema.ts changes", async () => {
|
||||
const result = await UpdateService.checkUpdateRequirements("main");
|
||||
|
||||
expect(result.needsInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
|
||||
expect(result.needsRootInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should call git diff with correct branch", async () => {
|
||||
await UpdateService.checkUpdateRequirements("develop");
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installDependencies", () => {
|
||||
test("should run bun install and return output", async () => {
|
||||
const output = await UpdateService.installDependencies();
|
||||
test("should run bun install for root only", async () => {
|
||||
const output = await UpdateService.installDependencies({ root: true, web: false });
|
||||
|
||||
expect(output).toBe("Installed dependencies");
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall![0]).toBe("bun install");
|
||||
expect(output).toContain("Root");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
|
||||
});
|
||||
|
||||
test("should run bun install for both root and web in parallel", async () => {
|
||||
const output = await UpdateService.installDependencies({ root: true, web: true });
|
||||
|
||||
expect(output).toContain("Root");
|
||||
expect(output).toContain("Web");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorizeChanges", () => {
|
||||
test("should categorize files correctly", () => {
|
||||
const files = [
|
||||
"bot/commands/admin/update.ts",
|
||||
"bot/modules/admin/update.view.ts",
|
||||
"web/src/components/Button.tsx",
|
||||
"shared/lib/utils.ts",
|
||||
"package.json",
|
||||
"drizzle/0001_migration.sql"
|
||||
];
|
||||
|
||||
const categories = UpdateService.categorizeChanges(files);
|
||||
|
||||
expect(categories["Commands"]).toBe(1);
|
||||
expect(categories["Modules"]).toBe(1);
|
||||
expect(categories["Web Dashboard"]).toBe(1);
|
||||
expect(categories["Library"]).toBe(1);
|
||||
expect(categories["Dependencies"]).toBe(1);
|
||||
expect(categories["Database"]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +178,10 @@ describe("UpdateService", () => {
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true,
|
||||
installDependencies: false
|
||||
installDependencies: false,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc1234",
|
||||
newCommit: "def5678"
|
||||
};
|
||||
|
||||
await UpdateService.prepareRestartContext(context);
|
||||
@@ -143,6 +194,39 @@ describe("UpdateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveRollbackPoint", () => {
|
||||
test("should save current commit hash to file", async () => {
|
||||
const commit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
expect(commit).toBeTruthy();
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall![0]).toContain("rollback_commit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRollbackPoint", () => {
|
||||
test("should return true when rollback file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123"));
|
||||
|
||||
// Clear cache first
|
||||
(UpdateService as any).rollbackPointExists = null;
|
||||
|
||||
const result = await UpdateService.hasRollbackPoint();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when rollback file does not exist", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
// Clear cache first
|
||||
(UpdateService as any).rollbackPointExists = null;
|
||||
|
||||
const result = await UpdateService.hasRollbackPoint();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerRestart", () => {
|
||||
test("should use RESTART_COMMAND env var when set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
@@ -150,24 +234,19 @@ describe("UpdateService", () => {
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
const lastCall = mockExec.mock.lastCall;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toBe("pm2 restart bot");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true);
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
|
||||
test("should write to trigger file when no env var", async () => {
|
||||
test("should call process.exit when no env var is set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
delete process.env.RESTART_COMMAND;
|
||||
|
||||
// Just verify it doesn't throw - actual process.exit is mocked by setTimeout
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_trigger");
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
});
|
||||
@@ -181,7 +260,7 @@ describe("UpdateService", () => {
|
||||
|
||||
const createMockChannel = () => ({
|
||||
isSendable: () => true,
|
||||
send: mock(() => Promise.resolve())
|
||||
send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }))
|
||||
});
|
||||
|
||||
test("should ignore stale context (>10 mins old)", async () => {
|
||||
@@ -190,7 +269,10 @@ describe("UpdateService", () => {
|
||||
userId: "456",
|
||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||
runMigrations: true,
|
||||
installDependencies: true
|
||||
installDependencies: true,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc",
|
||||
newCommit: "def"
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||
@@ -227,7 +309,10 @@ describe("UpdateService", () => {
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: false,
|
||||
installDependencies: false
|
||||
installDependencies: false,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc",
|
||||
newCommit: "def"
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||
@@ -236,7 +321,7 @@ describe("UpdateService", () => {
|
||||
const { TextChannel } = await import("discord.js");
|
||||
const mockChannel = Object.create(TextChannel.prototype);
|
||||
mockChannel.isSendable = () => true;
|
||||
mockChannel.send = mock(() => Promise.resolve());
|
||||
mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }));
|
||||
|
||||
const mockClient = createMockClient(mockChannel);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user