diff --git a/apps/app/package.json b/apps/app/package.json index e564250f..5eff2d74 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -21,6 +21,7 @@ "dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"", "build": "next build", "build:electron": "next build && electron-builder", + "postinstall": "electron-builder install-app-deps", "start": "next start", "lint": "eslint", "test": "playwright test", @@ -79,7 +80,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9.2.1", - "electron": "^39.2.6", + "electron": "39.2.7", "electron-builder": "^26.0.12", "eslint": "^9", "eslint-config-next": "16.0.7", diff --git a/apps/server/tests/unit/services/terminal-service.test.ts b/apps/server/tests/unit/services/terminal-service.test.ts new file mode 100644 index 00000000..d273061a --- /dev/null +++ b/apps/server/tests/unit/services/terminal-service.test.ts @@ -0,0 +1,567 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TerminalService, getTerminalService } from "@/services/terminal-service.js"; +import * as pty from "node-pty"; +import * as os from "os"; +import * as fs from "fs"; + +vi.mock("node-pty"); +vi.mock("fs"); +vi.mock("os"); + +describe("terminal-service.ts", () => { + let service: TerminalService; + let mockPtyProcess: any; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TerminalService(); + + // Mock PTY process + mockPtyProcess = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + }; + + vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); + vi.mocked(os.homedir).mockReturnValue("/home/user"); + vi.mocked(os.platform).mockReturnValue("linux"); + vi.mocked(os.arch).mockReturnValue("x64"); + }); + + afterEach(() => { + service.cleanup(); + }); + + describe("detectShell", () => { + it("should detect PowerShell Core on Windows when available", () => { + vi.mocked(os.platform).mockReturnValue("win32"); + vi.mocked(fs.existsSync).mockImplementation((path: any) => { + return path === "C:\\Program Files\\PowerShell\\7\\pwsh.exe"; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe"); + expect(result.args).toEqual([]); + }); + + it("should fall back to PowerShell on Windows if Core not available", () => { + vi.mocked(os.platform).mockReturnValue("win32"); + vi.mocked(fs.existsSync).mockImplementation((path: any) => { + return path === "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + expect(result.args).toEqual([]); + }); + + it("should fall back to cmd.exe on Windows if no PowerShell", () => { + vi.mocked(os.platform).mockReturnValue("win32"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = service.detectShell(); + + expect(result.shell).toBe("cmd.exe"); + expect(result.args).toEqual([]); + }); + + it("should detect user shell on macOS", () => { + vi.mocked(os.platform).mockReturnValue("darwin"); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/zsh" }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/zsh"); + expect(result.args).toEqual(["--login"]); + }); + + it("should fall back to zsh on macOS if user shell not available", () => { + vi.mocked(os.platform).mockReturnValue("darwin"); + vi.spyOn(process, "env", "get").mockReturnValue({}); + vi.mocked(fs.existsSync).mockImplementation((path: any) => { + return path === "/bin/zsh"; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/zsh"); + expect(result.args).toEqual(["--login"]); + }); + + it("should fall back to bash on macOS if zsh not available", () => { + vi.mocked(os.platform).mockReturnValue("darwin"); + vi.spyOn(process, "env", "get").mockReturnValue({}); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/bash"); + expect(result.args).toEqual(["--login"]); + }); + + it("should detect user shell on Linux", () => { + vi.mocked(os.platform).mockReturnValue("linux"); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/bash"); + expect(result.args).toEqual(["--login"]); + }); + + it("should fall back to bash on Linux if user shell not available", () => { + vi.mocked(os.platform).mockReturnValue("linux"); + vi.spyOn(process, "env", "get").mockReturnValue({}); + vi.mocked(fs.existsSync).mockImplementation((path: any) => { + return path === "/bin/bash"; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/bash"); + expect(result.args).toEqual(["--login"]); + }); + + it("should fall back to sh on Linux if bash not available", () => { + vi.mocked(os.platform).mockReturnValue("linux"); + vi.spyOn(process, "env", "get").mockReturnValue({}); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/sh"); + expect(result.args).toEqual([]); + }); + + it("should detect WSL and use appropriate shell", () => { + vi.mocked(os.platform).mockReturnValue("linux"); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); + + const result = service.detectShell(); + + expect(result.shell).toBe("/bin/bash"); + expect(result.args).toEqual(["--login"]); + }); + }); + + describe("isWSL", () => { + it("should return true if /proc/version contains microsoft", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-microsoft-standard-WSL2"); + + expect(service.isWSL()).toBe(true); + }); + + it("should return true if /proc/version contains wsl", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-wsl2"); + + expect(service.isWSL()).toBe(true); + }); + + it("should return true if WSL_DISTRO_NAME is set", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.spyOn(process, "env", "get").mockReturnValue({ WSL_DISTRO_NAME: "Ubuntu" }); + + expect(service.isWSL()).toBe(true); + }); + + it("should return true if WSLENV is set", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.spyOn(process, "env", "get").mockReturnValue({ WSLENV: "PATH/l" }); + + expect(service.isWSL()).toBe(true); + }); + + it("should return false if not in WSL", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.spyOn(process, "env", "get").mockReturnValue({}); + + expect(service.isWSL()).toBe(false); + }); + + it("should return false if error reading /proc/version", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("Permission denied"); + }); + + expect(service.isWSL()).toBe(false); + }); + }); + + describe("getPlatformInfo", () => { + it("should return platform information", () => { + vi.mocked(os.platform).mockReturnValue("linux"); + vi.mocked(os.arch).mockReturnValue("x64"); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const info = service.getPlatformInfo(); + + expect(info.platform).toBe("linux"); + expect(info.arch).toBe("x64"); + expect(info.defaultShell).toBe("/bin/bash"); + expect(typeof info.isWSL).toBe("boolean"); + }); + }); + + describe("createSession", () => { + it("should create a new terminal session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession({ + cwd: "/test/dir", + cols: 100, + rows: 30, + }); + + expect(session.id).toMatch(/^term-/); + expect(session.cwd).toBe("/test/dir"); + expect(session.shell).toBe("/bin/bash"); + expect(pty.spawn).toHaveBeenCalledWith( + "/bin/bash", + ["--login"], + expect.objectContaining({ + cwd: "/test/dir", + cols: 100, + rows: 30, + }) + ); + }); + + it("should use default cols and rows if not provided", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + service.createSession(); + + expect(pty.spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + cols: 80, + rows: 24, + }) + ); + }); + + it("should fall back to home directory if cwd does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession({ + cwd: "/nonexistent", + }); + + expect(session.cwd).toBe("/home/user"); + }); + + it("should fall back to home directory if cwd is not a directory", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => false } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession({ + cwd: "/file.txt", + }); + + expect(session.cwd).toBe("/home/user"); + }); + + it("should fix double slashes in path", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession({ + cwd: "//test/dir", + }); + + expect(session.cwd).toBe("/test/dir"); + }); + + it("should preserve WSL UNC paths", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession({ + cwd: "//wsl$/Ubuntu/home", + }); + + expect(session.cwd).toBe("//wsl$/Ubuntu/home"); + }); + + it("should handle data events from PTY", () => { + vi.useFakeTimers(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const dataCallback = vi.fn(); + service.onData(dataCallback); + + service.createSession(); + + // Simulate data event + const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; + onDataHandler("test data"); + + // Wait for throttled output + vi.advanceTimersByTime(20); + + expect(dataCallback).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it("should handle exit events from PTY", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const exitCallback = vi.fn(); + service.onExit(exitCallback); + + const session = service.createSession(); + + // Simulate exit event + const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0]; + onExitHandler({ exitCode: 0 }); + + expect(exitCallback).toHaveBeenCalledWith(session.id, 0); + expect(service.getSession(session.id)).toBeUndefined(); + }); + }); + + describe("write", () => { + it("should write data to existing session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession(); + const result = service.write(session.id, "ls\n"); + + expect(result).toBe(true); + expect(mockPtyProcess.write).toHaveBeenCalledWith("ls\n"); + }); + + it("should return false for non-existent session", () => { + const result = service.write("nonexistent", "data"); + + expect(result).toBe(false); + expect(mockPtyProcess.write).not.toHaveBeenCalled(); + }); + }); + + describe("resize", () => { + it("should resize existing session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession(); + const result = service.resize(session.id, 120, 40); + + expect(result).toBe(true); + expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); + }); + + it("should return false for non-existent session", () => { + const result = service.resize("nonexistent", 120, 40); + + expect(result).toBe(false); + expect(mockPtyProcess.resize).not.toHaveBeenCalled(); + }); + + it("should handle resize errors", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + mockPtyProcess.resize.mockImplementation(() => { + throw new Error("Resize failed"); + }); + + const session = service.createSession(); + const result = service.resize(session.id, 120, 40); + + expect(result).toBe(false); + }); + }); + + describe("killSession", () => { + it("should kill existing session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession(); + const result = service.killSession(session.id); + + expect(result).toBe(true); + expect(mockPtyProcess.kill).toHaveBeenCalled(); + expect(service.getSession(session.id)).toBeUndefined(); + }); + + it("should return false for non-existent session", () => { + const result = service.killSession("nonexistent"); + + expect(result).toBe(false); + }); + + it("should handle kill errors", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + mockPtyProcess.kill.mockImplementation(() => { + throw new Error("Kill failed"); + }); + + const session = service.createSession(); + const result = service.killSession(session.id); + + expect(result).toBe(false); + }); + }); + + describe("getSession", () => { + it("should return existing session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession(); + const retrieved = service.getSession(session.id); + + expect(retrieved).toBe(session); + }); + + it("should return undefined for non-existent session", () => { + const retrieved = service.getSession("nonexistent"); + + expect(retrieved).toBeUndefined(); + }); + }); + + describe("getScrollback", () => { + it("should return scrollback buffer for existing session", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session = service.createSession(); + session.scrollbackBuffer = "test scrollback"; + + const scrollback = service.getScrollback(session.id); + + expect(scrollback).toBe("test scrollback"); + }); + + it("should return null for non-existent session", () => { + const scrollback = service.getScrollback("nonexistent"); + + expect(scrollback).toBeNull(); + }); + }); + + describe("getAllSessions", () => { + it("should return all active sessions", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session1 = service.createSession({ cwd: "/dir1" }); + const session2 = service.createSession({ cwd: "/dir2" }); + + const sessions = service.getAllSessions(); + + expect(sessions).toHaveLength(2); + expect(sessions[0].id).toBe(session1.id); + expect(sessions[1].id).toBe(session2.id); + expect(sessions[0].cwd).toBe("/dir1"); + expect(sessions[1].cwd).toBe("/dir2"); + }); + + it("should return empty array if no sessions", () => { + const sessions = service.getAllSessions(); + + expect(sessions).toEqual([]); + }); + }); + + describe("onData and onExit", () => { + it("should allow subscribing and unsubscribing from data events", () => { + const callback = vi.fn(); + const unsubscribe = service.onData(callback); + + expect(typeof unsubscribe).toBe("function"); + + unsubscribe(); + }); + + it("should allow subscribing and unsubscribing from exit events", () => { + const callback = vi.fn(); + const unsubscribe = service.onExit(callback); + + expect(typeof unsubscribe).toBe("function"); + + unsubscribe(); + }); + }); + + describe("cleanup", () => { + it("should clean up all sessions", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + + const session1 = service.createSession(); + const session2 = service.createSession(); + + service.cleanup(); + + expect(service.getSession(session1.id)).toBeUndefined(); + expect(service.getSession(session2.id)).toBeUndefined(); + expect(service.getAllSessions()).toHaveLength(0); + }); + + it("should handle cleanup errors gracefully", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ isDirectory: () => true } as any); + vi.spyOn(process, "env", "get").mockReturnValue({ SHELL: "/bin/bash" }); + mockPtyProcess.kill.mockImplementation(() => { + throw new Error("Kill failed"); + }); + + service.createSession(); + + expect(() => service.cleanup()).not.toThrow(); + }); + }); + + describe("getTerminalService", () => { + it("should return singleton instance", () => { + const instance1 = getTerminalService(); + const instance2 = getTerminalService(); + + expect(instance1).toBe(instance2); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 5c93bb76..15746bf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "apps/app": { "name": "@automaker/app", "version": "0.1.0", + "hasInstallScript": true, "license": "Unlicense", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -56,7 +57,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9.2.1", - "electron": "^39.2.6", + "electron": "39.2.7", "electron-builder": "^26.0.12", "eslint": "^9", "eslint-config-next": "16.0.7",