/**
* Tests for the SessionSidebar footer:
* - current version display
* - update check flow (2s delay, 24h interval)
* - download / error / dismiss state transitions
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
// --- Module mocks (hoisted by Vitest) -----------------------------------------
vi.mock("@tauri-apps/api/app", () => ({
getVersion: vi.fn(),
}));
vi.mock("../lib/updater", () => ({
checkForUpdate: vi.fn(),
downloadAndInstall: vi.fn(),
}));
vi.mock("../lib/commands", () => ({
listSessions: vi.fn().mockResolvedValue([]),
cleanSessions: vi.fn().mockResolvedValue({ deleted: 0, skipped: 0 }),
approveSession: vi.fn(),
cancelSession: vi.fn(),
createSession: vi.fn(),
deleteSession: vi.fn(),
fixSession: vi.fn(),
getSession: vi.fn(),
getSessionLog: vi.fn(),
getSessionPlan: vi.fn(),
getUpdateReadiness: vi.fn().mockResolvedValue({ canAutoUpdate: true }),
listConfigs: vi.fn().mockResolvedValue([]),
listDirectory: vi.fn().mockResolvedValue([]),
resetSession: vi.fn(),
respondToOption: vi.fn(),
runSession: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
Channel: vi.fn().mockImplementation(() => ({ onmessage: null })),
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
// --- Imports after mocks -------------------------------------------------------
import { getVersion } from "@tauri-apps/api/app";
import { checkForUpdate, downloadAndInstall } from "../lib/updater";
import type { Update } from "../lib/updater";
import { getUpdateReadiness } from "../lib/commands";
import { SessionSidebar } from "../components/SessionSidebar";
// --- Helpers ------------------------------------------------------------------
function makeUpdate(version: string): Update {
return { version } as unknown as Update;
}
const defaultProps = {
selectedId: null as string | null,
onSelect: vi.fn(),
onNewSession: vi.fn(),
onRunAll: vi.fn(),
};
// --- Tests: Version display ---------------------------------------------------
describe("SessionSidebar footer - version display", () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue("0.1.21");
vi.mocked(checkForUpdate).mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
it("displays the version number returned by getVersion() in the footer", async () => {
// Given: getVersion() returns '0.1.21'
// When: SessionSidebar is mounted
render(<SessionSidebar {...defaultProps} />);
// Then: 'v0.1.21' is displayed in the footer
await screen.findByText(/v0\.1\.21/);
});
});
// --- Tests: Update check ------------------------------------------------------
describe("SessionSidebar footer - update check", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getVersion).mockResolvedValue("0.1.21");
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("does not show Update button when no update is available", async () => {
// Given: checkForUpdate() returns null
vi.mocked(checkForUpdate).mockResolvedValue(null);
// When: 2 seconds elapse
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: version is displayed but no Update button
expect(screen.getByText(/v0\.1\.21/)).toBeTruthy();
expect(screen.queryByRole("button", { name: /update/i })).toBeNull();
});
it("shows new version info 2 seconds after update is available", async () => {
// Given: checkForUpdate() returns v0.1.22
vi.mocked(checkForUpdate).mockResolvedValue(makeUpdate("0.1.22"));
render(<SessionSidebar {...defaultProps} />);
// When: update info is not shown before 2 seconds elapse
expect(screen.queryByText(/0\.1\.22/)).toBeNull();
// When: after 2 seconds elapse
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: new version info is displayed
expect(screen.getByText(/0\.1\.22/)).toBeTruthy();
});
it("shows Update button when update is available", async () => {
// Given: checkForUpdate() returns an update
vi.mocked(checkForUpdate).mockResolvedValue(makeUpdate("0.1.22"));
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: Update button is displayed
expect(screen.getByRole("button", { name: /update/i })).toBeTruthy();
});
it("re-runs checkForUpdate() 24 hours after the initial check", async () => {
// Given: initial check complete
vi.mocked(checkForUpdate).mockResolvedValue(null);
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(vi.mocked(checkForUpdate)).toHaveBeenCalledTimes(1);
// When: 24 hours elapse
await act(() => vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000));
// Then: checkForUpdate is called again
expect(vi.mocked(checkForUpdate)).toHaveBeenCalledTimes(2);
});
});
// --- Tests: Update flow -------------------------------------------------------
describe("SessionSidebar footer - update flow", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getVersion).mockResolvedValue("0.1.21");
vi.mocked(checkForUpdate).mockResolvedValue(makeUpdate("0.1.22"));
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
/** Renders until the Update button is visible */
async function renderWithUpdate() {
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
expect(screen.getByRole("button", { name: /update/i })).toBeTruthy();
}
it("enters downloading state when Update button is clicked", async () => {
// Given: downloadAndInstall() stays pending (simulating in-progress)
vi.mocked(downloadAndInstall).mockImplementation(
() => new Promise<void>(() => {}),
);
await renderWithUpdate();
// When: Update button is clicked
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /update/i }));
});
// Then: a display indicating downloading appears
expect(screen.getByText(/downloading/i)).toBeTruthy();
});
it("shows error message and Dismiss button on download error", async () => {
// Given: downloadAndInstall() throws an error
vi.mocked(downloadAndInstall).mockRejectedValue(new Error("Network error"));
await renderWithUpdate();
// When: Update button is clicked
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /update/i }));
await Promise.resolve();
});
// Then: error message and Dismiss button are displayed
expect(screen.getByText(/network error/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /dismiss/i })).toBeTruthy();
});
it("resets error state when Dismiss button is clicked", async () => {
// Given: download failure -> error state
vi.mocked(downloadAndInstall).mockRejectedValue(new Error("Network error"));
await renderWithUpdate();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /update/i }));
await Promise.resolve();
});
expect(screen.getByRole("button", { name: /dismiss/i })).toBeTruthy();
// When: Dismiss is clicked
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
});
// Then: error message disappears
expect(screen.queryByText(/network error/i)).toBeNull();
});
});
// --- Tests: Update readiness guard -------------------------------------------
describe("SessionSidebar footer - update readiness guard", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getVersion).mockResolvedValue("0.1.21");
// An update IS available so we can confirm the button is suppressed by readiness
vi.mocked(checkForUpdate).mockResolvedValue(makeUpdate("0.1.22"));
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("hides the Update button when the app is running from App Translocation", async () => {
// Given: app is running from App Translocation (macOS Gatekeeper sandbox)
vi.mocked(getUpdateReadiness).mockResolvedValue({
canAutoUpdate: false,
reason: "translocated",
bundlePath: "/private/var/folders/xx/AppTranslocation/GUID/d/cruise.app",
guidance: "Move cruise.app to /Applications, then run xattr -cr /Applications/cruise.app",
});
// When: component is mounted and 2 seconds elapse
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: Update button is not shown
expect(screen.queryByRole("button", { name: /update/i })).toBeNull();
});
it("shows a warning message when the app is running from App Translocation", async () => {
// Given: app is running from App Translocation
vi.mocked(getUpdateReadiness).mockResolvedValue({
canAutoUpdate: false,
reason: "translocated",
bundlePath: "/private/var/folders/xx/AppTranslocation/GUID/d/cruise.app",
guidance: "Move cruise.app to /Applications, then run xattr -cr /Applications/cruise.app",
});
// When: component is mounted
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: guidance mentioning /Applications is displayed
expect(screen.getByText(/\/Applications/)).toBeTruthy();
});
it("hides the Update button when the app is running from a mounted DMG volume", async () => {
// Given: app is running directly from a mounted DMG
vi.mocked(getUpdateReadiness).mockResolvedValue({
canAutoUpdate: false,
reason: "mountedVolume",
bundlePath: "/Volumes/cruise 0.1.21/cruise.app",
guidance: "Copy cruise.app to /Applications before using auto-update",
});
// When: component is mounted and 2 seconds elapse
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: Update button is not shown
expect(screen.queryByRole("button", { name: /update/i })).toBeNull();
});
it("shows a warning message when the app is running from a mounted DMG volume", async () => {
// Given: app is running from a mounted DMG
vi.mocked(getUpdateReadiness).mockResolvedValue({
canAutoUpdate: false,
reason: "mountedVolume",
bundlePath: "/Volumes/cruise 0.1.21/cruise.app",
guidance: "Copy cruise.app to /Applications before using auto-update",
});
// When: component is mounted
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: guidance mentioning /Applications is displayed
expect(screen.getByText(/\/Applications/)).toBeTruthy();
});
it("shows the Update button when readiness check returns canAutoUpdate true", async () => {
// Given: app is properly installed (readiness OK)
vi.mocked(getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
// When: component is mounted and 2 seconds elapse
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: Update button IS shown (normal behavior)
expect(screen.getByRole("button", { name: /update/i })).toBeTruthy();
});
it("still displays the current version when readiness blocks auto-update", async () => {
// Given: app is running from App Translocation
vi.mocked(getUpdateReadiness).mockResolvedValue({
canAutoUpdate: false,
reason: "translocated",
guidance: "Move cruise.app to /Applications",
});
// When: component is mounted
render(<SessionSidebar {...defaultProps} />);
await act(() => vi.advanceTimersByTimeAsync(2000));
// Then: version number is still visible even though update is blocked
expect(screen.getByText(/v0\.1\.21/)).toBeTruthy();
});
});