diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx index 8fc91c01..2cde8a90 100644 --- a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -1,7 +1,7 @@ import type { ComponentProps } from "react"; import { afterEach, describe, expect, test } from "bun:test"; import { HttpResponse, http, server } from "~/test/msw/server"; -import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; +import { cleanup, fireEvent, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; type SnapshotFilesRequest = { shortId: string; @@ -82,7 +82,7 @@ describe("SnapshotTreeBrowser", () => { expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); }); - test("renders a single file when no display base path is available", async () => { + test("renders synthesized ancestor folders for a single file when no display base path is available", async () => { const requests = mockListSnapshotFiles({ files: [{ name: "report.txt", path: "/mnt/project/report.txt", type: "file" }], }); @@ -92,7 +92,14 @@ describe("SnapshotTreeBrowser", () => { displayBasePath: undefined, }); - expect(await screen.findByRole("button", { name: "report.txt" })).toBeTruthy(); + const mntRow = await screen.findByRole("button", { name: "mnt" }); + const mntExpandIcon = mntRow.querySelector("svg"); + if (!mntExpandIcon) { + throw new Error("Expected expand icon for mnt row"); + } + fireEvent.click(mntExpandIcon); + + expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); expect(requests[0]).toEqual({ shortId: "repo-1", snapshotId: "snap-1", @@ -252,7 +259,7 @@ describe("SnapshotTreeBrowser", () => { } const initialRequestCount = requests.length; - await userEvent.click(expandIcon); + fireEvent.click(expandIcon); await waitFor(() => { expect(requests.length).toBeGreaterThan(initialRequestCount); diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index 47763725..b7569263 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -114,20 +114,25 @@ export const FileTree = memo((props: Props) => { const toggleCollapseState = useCallback( (fullPath: string) => { + const shouldExpand = collapsedFolders.has(fullPath); + setCollapsedFolders((prevSet) => { const newSet = new Set(prevSet); if (newSet.has(fullPath)) { newSet.delete(fullPath); - onFolderExpand?.(fullPath); } else { newSet.add(fullPath); } return newSet; }); + + if (shouldExpand) { + onFolderExpand?.(fullPath); + } }, - [onFolderExpand], + [collapsedFolders, onFolderExpand], ); // Add new folders to collapsed set when file list changes diff --git a/app/client/hooks/use-file-browser.ts b/app/client/hooks/use-file-browser.ts index 81244638..90a8a6b7 100644 --- a/app/client/hooks/use-file-browser.ts +++ b/app/client/hooks/use-file-browser.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { logger } from "~/client/lib/logger"; import type { FileEntry } from "../components/file-tree"; @@ -45,7 +45,7 @@ export const useFileBrowser = (props: UseFileBrowserOptions) => { const stripPath = pathTransform?.strip; const addPath = pathTransform?.add; - useMemo(() => { + useEffect(() => { if (initialData?.files) { const files = initialData.files; setAllFiles((prev) => { diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index cd65a5e3..f13c4703 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -218,12 +218,24 @@ describe("stop backup", () => { repositoryId: repository.id, }); - resticBackupMock.mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 500)); - return { exitCode: 0, summary: generateBackupOutput(), error: "" }; + resticBackupMock.mockImplementation(({ signal }: SafeSpawnParams) => { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve({ exitCode: 1, summary: "", error: "" }); + return; + } + + signal?.addEventListener( + "abort", + () => { + resolve({ exitCode: 1, summary: "", error: "" }); + }, + { once: true }, + ); + }); }); - void backupsExecutionService.executeBackup(schedule.id); + const executePromise = backupsExecutionService.executeBackup(schedule.id); await waitForExpect(async () => { const runningSchedule = await backupsService.getScheduleById(schedule.id); @@ -232,6 +244,7 @@ describe("stop backup", () => { // act await backupsExecutionService.stopBackup(schedule.id); + await executePromise; // assert const updatedSchedule = await backupsService.getScheduleById(schedule.id); @@ -248,30 +261,39 @@ describe("stop backup", () => { repositoryId: repository.id, }); - const releaseLock = await repoMutex.acquireExclusive(repository.id, "test"); + spyOn(repoMutex, "acquireShared").mockImplementation((_repositoryId, _operation, signal) => { + return new Promise((_, reject) => { + if (signal?.aborted) { + reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted")); + return; + } - try { - const executePromise = backupsExecutionService.executeBackup(schedule.id); - - await waitForExpect(async () => { - const queuedSchedule = await backupsService.getScheduleById(schedule.id); - expect(queuedSchedule.lastBackupStatus).toBe("in_progress"); + signal?.addEventListener( + "abort", + () => { + reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted")); + }, + { once: true }, + ); }); + }); - expect(resticBackupMock).not.toHaveBeenCalled(); + const executePromise = backupsExecutionService.executeBackup(schedule.id); - await backupsExecutionService.stopBackup(schedule.id); - releaseLock(); + await waitForExpect(async () => { + const queuedSchedule = await backupsService.getScheduleById(schedule.id); + expect(queuedSchedule.lastBackupStatus).toBe("in_progress"); + }); - await executePromise; + expect(resticBackupMock).not.toHaveBeenCalled(); - const updatedSchedule = await backupsService.getScheduleById(schedule.id); - expect(updatedSchedule.lastBackupStatus).toBe("warning"); - expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user"); - expect(resticBackupMock).not.toHaveBeenCalled(); - } finally { - releaseLock(); - } + await backupsExecutionService.stopBackup(schedule.id); + await executePromise; + + const updatedSchedule = await backupsService.getScheduleById(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("warning"); + expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user"); + expect(resticBackupMock).not.toHaveBeenCalled(); }); test("should throw ConflictError when trying to stop non-running backup", async () => {