Skip to content
Draft
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
19 changes: 19 additions & 0 deletions desktop/src/main/__tests__/app-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest"
import {
clearAppQuitting,
isAppQuitting,
markAppQuitting,
} from "../app-lifecycle.js"

describe("app lifecycle", () => {
it("starts as not quitting", () => {
expect(isAppQuitting()).toBe(false)
})

it("marks the application as quitting", () => {
markAppQuitting()
expect(isAppQuitting()).toBe(true)
clearAppQuitting()
expect(isAppQuitting()).toBe(false)
})
})
17 changes: 17 additions & 0 deletions desktop/src/main/__tests__/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ describe("DaemonState", () => {
expect(state.updateWorkspaces(ws)).toBe(false)
})

it("updates workspace status and notifies listeners only for changes", () => {
const state = new DaemonState()
const listener = vi.fn()
const unsubscribe = state.onWorkspacesChange(listener)
state.updateWorkspaces([makeWorkspace("ws1", "2024-01-01")])
listener.mockClear()

expect(state.updateWorkspaceStatus("ws1", "running")).toBe(true)
expect(listener).toHaveBeenCalledTimes(1)
expect(state.updateWorkspaceStatus("ws1", "running")).toBe(false)
expect(listener).toHaveBeenCalledTimes(1)
expect(state.updateWorkspaceStatus("missing", "running")).toBe(false)
unsubscribe()
expect(state.updateWorkspaceStatus("ws1", "stopped")).toBe(true)
expect(listener).toHaveBeenCalledTimes(1)
})

it("detects workspace removal", () => {
const state = new DaemonState()
expect(
Expand Down
81 changes: 80 additions & 1 deletion desktop/src/main/__tests__/tray.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest"
import { buildUpdateMenuItems } from "../tray.js"
import { buildTrayMenuTemplate, buildUpdateMenuItems } from "../tray.js"

vi.mock("electron", () => ({}))
vi.mock("../updater.js", () => ({
getLastStatus: () => ({ state: "idle" }),
installUpdate: vi.fn(),
onUpdateStatusChanged: vi.fn(() => () => {}),
}))

describe("buildUpdateMenuItems", () => {
Expand All @@ -15,6 +16,12 @@ describe("buildUpdateMenuItems", () => {
expect(buildUpdateMenuItems({ state: "downloading", version: "1" }, () => {})).toEqual([])
expect(buildUpdateMenuItems({ state: "not-available" }, () => {})).toEqual([])
expect(buildUpdateMenuItems({ state: "error", error: "x" }, () => {})).toEqual([])
expect(
buildUpdateMenuItems(
{ state: "error", code: "install-failed", version: "1" },
() => {},
),
).toHaveLength(2)
})

it("adds Install Update item + separator when downloaded", () => {
Expand All @@ -33,4 +40,76 @@ describe("buildUpdateMenuItems", () => {
const items = buildUpdateMenuItems({ state: "downloaded" }, () => {})
expect(items[0]).toMatchObject({ label: "Install Update v" })
})

it("offers retry after installation fails", () => {
const onInstall = vi.fn()
const items = buildUpdateMenuItems(
{ state: "error", code: "install-failed", version: "9.9.9" },
onInstall,
)
expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" })
;(items[0] as { click?: () => void }).click?.()
expect(onInstall).toHaveBeenCalledTimes(1)
})
})

describe("buildTrayMenuTemplate", () => {
const actions = {
showDevsy: vi.fn(),
showWorkspace: vi.fn(),
showAllWorkspaces: vi.fn(),
stopWorkspace: vi.fn(),
installUpdate: vi.fn(),
quit: vi.fn(),
}

it("shows only active workspaces with open and stop actions", () => {
const items = buildTrayMenuTemplate(
{
activeWorkspaces: [
{ id: "running", status: "running" },
{ id: "busy", status: "busy" },
],
pendingStops: new Set(),
updateStatus: { state: "idle" },
},
actions,
)
expect(items[0]).toMatchObject({ label: "Show Devsy" })
const submenu = items[2].submenu as Array<Record<string, unknown>>
expect(submenu[0]).toMatchObject({ label: "running" })
expect(submenu[1]).toMatchObject({ label: "busy — Busy" })
expect(submenu[0].submenu).toEqual([
expect.objectContaining({ label: "Open in Devsy" }),
expect.objectContaining({ label: "Stop Workspace" }),
])
expect(items.at(-1)).toMatchObject({ label: "Quit Devsy" })
})

it("shows an empty state and disables a pending stop", () => {
const empty = buildTrayMenuTemplate(
{
activeWorkspaces: [],
pendingStops: new Set(),
updateStatus: { state: "idle" },
},
actions,
)
expect((empty[2].submenu as Array<Record<string, unknown>>)[0]).toMatchObject({
label: "No Active Workspaces",
enabled: false,
})

const pending = buildTrayMenuTemplate(
{
activeWorkspaces: [{ id: "ws-1", status: "running" }],
pendingStops: new Set(["ws-1"]),
updateStatus: { state: "idle" },
},
actions,
)
const stop = ((pending[2].submenu as Array<Record<string, unknown>>)[0]
.submenu as Array<Record<string, unknown>>)[1]
expect(stop).toMatchObject({ label: "Stopping…", enabled: false })
})
})
63 changes: 50 additions & 13 deletions desktop/src/main/__tests__/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ vi.mock("electron-updater", () => ({
vi.mock("electron", () => ({
app: {
isPackaged: true,
isQuitting: false,
getPath: () => "/tmp/devsy-test",
getVersion: () => "1.0.0",
},
Expand Down Expand Up @@ -102,25 +101,63 @@ describe("updater", () => {
).not.toHaveBeenCalled()
})

it("sets app.isQuitting before quitAndInstall so the window can close", async () => {
const electron = await import("electron")
;(
electron.app as typeof electron.app & { isQuitting?: boolean }
).isQuitting = false
let quittingWhenInstalled: boolean | undefined
electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => {
quittingWhenInstalled = (
electron.app as typeof electron.app & { isQuitting?: boolean }
).isQuitting
})
it("marks the app as quitting before quitAndInstall", async () => {
const { installUpdate } = await import("../updater.js")
const { isAppQuitting } = await import("../app-lifecycle.js")
await installUpdate()
expect(quittingWhenInstalled).toBe(true)
expect(isAppQuitting()).toBe(true)
expect(
electronUpdaterMock.autoUpdater.quitAndInstall,
).toHaveBeenCalledTimes(1)
})

it("restores lifecycle state when quitAndInstall fails", async () => {
electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => {
throw new Error("install failed")
})
const { getLastStatus, initAutoUpdater, installUpdate } = await import(
"../updater.js"
)
const { isAppQuitting } = await import("../app-lifecycle.js")
const send = vi.fn()
const win = { isDestroyed: () => false, webContents: { send } } as never
await initAutoUpdater(() => win)
electronUpdaterMock.autoUpdater.emit("update-downloaded", {
version: "9.9.9",
})

await expect(installUpdate()).rejects.toThrow("install failed")
expect(isAppQuitting()).toBe(false)
expect(getLastStatus()).toMatchObject({
state: "error",
code: "install-failed",
version: "9.9.9",
})
})

it("continues notifying update listeners after one throws", async () => {
const { initAutoUpdater, onUpdateStatusChanged } = await import(
"../updater.js"
)
const send = vi.fn()
const win = { isDestroyed: () => false, webContents: { send } } as never
await initAutoUpdater(() => win)
const first = vi.fn(() => {
throw new Error("listener failed")
})
const second = vi.fn()
onUpdateStatusChanged(first)
onUpdateStatusChanged(second)

expect(() =>
electronUpdaterMock.autoUpdater.emit("update-available", {
version: "9.9.9",
}),
).not.toThrow()
expect(first).toHaveBeenCalled()
expect(second).toHaveBeenCalled()
})

it("swallows a channel-missing rejection from check_for_updates", async () => {
electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce(
new Error(
Expand Down
31 changes: 31 additions & 0 deletions desktop/src/main/__tests__/workspace-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import {
isActiveWorkspaceStatus,
normalizeWorkspaceStatus,
} from "../workspace-status.js"

describe("workspace status", () => {
it.each([
['{"state":"running"}', "running"],
['{"state":"busy"}', "busy"],
[" stopped ", "stopped"],
["not-json", "not-json"],
])("normalizes %j", (raw, expected) => {
expect(normalizeWorkspaceStatus(raw)).toBe(expected)
})

it("ignores empty or JSON without a state", () => {
expect(normalizeWorkspaceStatus(" ")).toBeUndefined()
expect(normalizeWorkspaceStatus("{}")).toBeUndefined()
})

it.each(["running", "RUNNING", "busy", "Busy"])(
"classifies %s as active",
(status) => expect(isActiveWorkspaceStatus(status)).toBe(true),
)

it.each([undefined, "stopped", "notfound", "unknown"])(
"classifies %s as inactive",
(status) => expect(isActiveWorkspaceStatus(status)).toBe(false),
)
})
13 changes: 13 additions & 0 deletions desktop/src/main/app-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
let quitting = false

export function markAppQuitting(): void {
quitting = true
}

export function clearAppQuitting(): void {
quitting = false
}

export function isAppQuitting(): boolean {
return quitting
}
Loading
Loading