Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,16 @@
"@types/express": "^5.0.5",
"@types/pidusage": "^2.0.5",
"@vercel/blob": "^2.0.0",
"@xterm/addon-clipboard": "0.3.0-beta.148",
"@xterm/addon-fit": "0.12.0-beta.148",
"@xterm/addon-image": "0.10.0-beta.148",
"@xterm/addon-ligatures": "0.11.0-beta.148",
"@xterm/addon-search": "0.17.0-beta.148",
"@xterm/addon-serialize": "0.15.0-beta.148",
"@xterm/addon-unicode11": "0.10.0-beta.148",
"@xterm/addon-webgl": "0.20.0-beta.147",
"@xterm/headless": "6.1.0-beta.148",
"@xterm/xterm": "6.1.0-beta.148",
"@xterm/addon-clipboard": "0.3.0-beta.195",
"@xterm/addon-fit": "0.12.0-beta.195",
"@xterm/addon-image": "0.10.0-beta.195",
"@xterm/addon-ligatures": "0.11.0-beta.195",
"@xterm/addon-search": "0.17.0-beta.195",
"@xterm/addon-serialize": "0.15.0-beta.195",
"@xterm/addon-unicode11": "0.10.0-beta.195",
"@xterm/addon-webgl": "0.20.0-beta.194",
"@xterm/headless": "6.1.0-beta.195",
"@xterm/xterm": "6.1.0-beta.195",
"ai": "^6.0.0",
"better-auth": "1.4.18",
"better-sqlite3": "12.6.2",
Expand Down
56 changes: 39 additions & 17 deletions apps/desktop/src/main/lib/host-service-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from "bun:test";
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";

Expand All @@ -23,29 +32,42 @@ const getProcessEnvWithShellPathMock = mock(
async (env: Record<string, string>) => env,
);
let lastChild: MockChildProcess | null = null;
const spawnMock = mock(() => {
const spawnMock = mock((..._args: unknown[]) => {
lastChild = new MockChildProcess();
return lastChild as unknown as ChildProcess;
});
let HostServiceManager: typeof import("./host-service-manager").HostServiceManager;

mock.module("electron", () => ({
app: {
isPackaged: false,
getAppPath: () => "/tmp/app",
},
}));

mock.module("../../lib/trpc/routers/workspaces/utils/shell-env", () => ({
getProcessEnvWithShellPath: getProcessEnvWithShellPathMock,
}));
describe("HostServiceManager", () => {
beforeAll(async () => {
const childProcessModule = await import("node:child_process");
const shellEnvModule = await import(
"../../lib/trpc/routers/workspaces/utils/shell-env"
);

mock.module("node:child_process", () => ({
spawn: spawnMock,
}));
spyOn(childProcessModule, "spawn").mockImplementation(((..._args) =>
spawnMock(..._args)) as typeof childProcessModule.spawn);
spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation(((
baseEnv: NodeJS.ProcessEnv = process.env,
) =>
getProcessEnvWithShellPathMock(
baseEnv as Record<string, string>,
)) as typeof shellEnvModule.getProcessEnvWithShellPath);

mock.module("electron", () => ({
app: {
isPackaged: false,
getAppPath: () => "/tmp/app",
},
}));

({ HostServiceManager } = await import("./host-service-manager"));
});

const { HostServiceManager } = await import("./host-service-manager");
afterAll(() => {
mock.restore();
});

describe("HostServiceManager", () => {
beforeEach(() => {
getProcessEnvWithShellPathMock.mockReset();
getProcessEnvWithShellPathMock.mockImplementation(
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/lib/host-service-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ChildProcess, spawn } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import * as childProcess from "node:child_process";
import path from "node:path";
import { app } from "electron";
import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env";
Expand Down Expand Up @@ -125,7 +126,7 @@ export class HostServiceManager {
throw new Error("Host service start cancelled");
}

const child = spawn(process.execPath, [this.scriptPath], {
const child = childProcess.spawn(process.execPath, [this.scriptPath], {
stdio: ["ignore", "pipe", "pipe"],
env,
});
Expand Down
21 changes: 12 additions & 9 deletions apps/desktop/src/main/lib/window-state/bounds-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
afterAll,
beforeEach,
describe,
expect,
Expand All @@ -7,7 +8,6 @@ import {
mock,
} from "bun:test";

// Mock electron with screen API before importing anything that uses it
const mockScreen = {
getPrimaryDisplay: mock(() => ({
workAreaSize: { width: 1920, height: 1080 },
Expand All @@ -21,19 +21,22 @@ const mockScreen = {
]),
};

mock.module("electron", () => ({
screen: mockScreen,
}));

// Import module after mocks are set up
const { getInitialWindowBounds, isVisibleOnAnyDisplay } = await import(
"./bounds-validation"
);
const { getInitialWindowBounds, isVisibleOnAnyDisplay, setScreenForTesting } =
await import("./bounds-validation");
const screen = mockScreen;

const MIN_VISIBLE_OVERLAP = 50;
const MIN_WINDOW_SIZE = 400;

beforeEach(() => {
setScreenForTesting(screen);
});

afterAll(() => {
setScreenForTesting(null);
mock.restore();
});

describe("isVisibleOnAnyDisplay", () => {
describe("single display setup", () => {
beforeEach(() => {
Expand Down
38 changes: 34 additions & 4 deletions apps/desktop/src/main/lib/window-state/bounds-validation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,46 @@
import type { Rectangle } from "electron";
import { screen } from "electron";
import type { WindowState } from "./window-state";

const MIN_VISIBLE_OVERLAP = 50;
const MIN_WINDOW_SIZE = 400;
interface DisplayBoundsLike {
bounds: Rectangle;
}

interface PrimaryDisplayLike {
workAreaSize: {
width: number;
height: number;
};
}

interface ScreenLike {
getAllDisplays(): DisplayBoundsLike[];
getPrimaryDisplay(): PrimaryDisplayLike;
}

let screenOverride: ScreenLike | null = null;

function getScreen(): ScreenLike {
if (screenOverride) {
return screenOverride;
}

// Resolve Electron lazily so Bun tests can inject a stub without relying on
// its unsupported named-export handling for the "electron" package.
return (require("electron") as typeof import("electron")).screen;
}

export function setScreenForTesting(screen: ScreenLike | null): void {
screenOverride = screen;
}

/**
* Checks if bounds overlap at least MIN_VISIBLE_OVERLAP pixels with any display.
* Returns false if window would be completely off-screen (e.g., monitor disconnected).
*/
export function isVisibleOnAnyDisplay(bounds: Rectangle): boolean {
const displays = screen.getAllDisplays();
const displays = getScreen().getAllDisplays();

return displays.some((display) => {
const db = display.bounds;
Expand All @@ -31,7 +61,7 @@ function clampToWorkArea(
width: number,
height: number,
): { width: number; height: number } {
const { workAreaSize } = screen.getPrimaryDisplay();
const { workAreaSize } = getScreen().getPrimaryDisplay();
return {
width: Math.min(Math.max(width, MIN_WINDOW_SIZE), workAreaSize.width),
height: Math.min(Math.max(height, MIN_WINDOW_SIZE), workAreaSize.height),
Expand All @@ -57,7 +87,7 @@ export interface InitialWindowBounds {
export function getInitialWindowBounds(
savedState: WindowState | null,
): InitialWindowBounds {
const { workAreaSize } = screen.getPrimaryDisplay();
const { workAreaSize } = getScreen().getPrimaryDisplay();

// No saved state → default to primary display size, centered
if (!savedState) {
Expand Down
40 changes: 20 additions & 20 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading