chore: update electron version to 39.2.7 and add postinstall script in package.json; add unit tests for terminal service

This commit is contained in:
Kacper
2025-12-13 21:28:22 +01:00
parent ff06821fcd
commit 1f4e801c58
3 changed files with 571 additions and 2 deletions

View File

@@ -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",

View File

@@ -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);
});
});
});

3
package-lock.json generated
View File

@@ -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",