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
2 changes: 1 addition & 1 deletion .github/actions/install-dependencies/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ runs:
- uses: oven-sh/setup-bun@v2
name: Install Bun
with:
bun-version: "1.3.6"
bun-version: "1.3.11"

- name: Install dependencies
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ qa-output

.worktrees/
.turbo
.superset
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ARG BUN_VERSION="1.3.6"
ARG BUN_VERSION="1.3.11"

FROM oven/bun:${BUN_VERSION}-alpine AS base

Expand Down
21 changes: 19 additions & 2 deletions app/client/components/__test__/file-tree.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
import { expect, test, describe } from "bun:test";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { afterEach, expect, test, describe } from "bun:test";
import { cleanup, render, screen, fireEvent, within } from "@testing-library/react";
import { useState } from "react";
import { FileTree, type FileEntry } from "../file-tree";

Expand Down Expand Up @@ -39,6 +39,10 @@ const getSelectedPaths = () => {
return JSON.parse(selectedPaths ?? "[]") as string[];
};

afterEach(() => {
cleanup();
});

describe("FileTree Pagination", () => {
const testFiles: FileEntry[] = [
{ name: "root", path: "/root", type: "folder" },
Expand Down Expand Up @@ -181,6 +185,19 @@ describe("FileTree Pagination", () => {

expect(screen.queryByText("Load more files")).toBeNull();
});

test("renders missing ancestor folders for nested paths", () => {
render(
<FileTree
files={[
{ name: "subdir", path: "/project/subdir", type: "folder" },
{ name: "file1", path: "/project/subdir/file1", type: "file" },
]}
/>,
);

expect(screen.getByRole("button", { name: "project" })).toBeTruthy();
});
});

describe("FileTree Selection Logic", () => {
Expand Down
78 changes: 78 additions & 0 deletions app/client/components/__test__/restore-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils";
import { fromAny } from "@total-typescript/shoehorn";

await mock.module("@tanstack/react-router", () => ({
useNavigate: () => mock(() => {}),
}));

import { RestoreForm } from "../restore-form";

class MockEventSource {
addEventListener() {}
close() {}
onerror: ((event: Event) => void) | null = null;

constructor(public url: string) {}
}

const originalEventSource = globalThis.EventSource;

beforeEach(() => {
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
});

afterEach(() => {
globalThis.EventSource = originalEventSource;
cleanup();
});

describe("RestoreForm", () => {
test("restores the selected ancestor folder path from a broader display root", async () => {
let restoreRequestBody: unknown;

server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => {
return HttpResponse.json({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "deep.tx", path: "/mnt/project/subdir/deep.tx", type: "file" },
],
});
}),
http.post("/api/v1/repositories/:shortId/restore", async ({ request }) => {
restoreRequestBody = await request.json();
return HttpResponse.json({
success: true,
message: "Snapshot restored successfully",
filesRestored: 1,
filesSkipped: 0,
});
}),
);

render(
<RestoreForm
repository={fromAny({ shortId: "repo-1", name: "Repo 1" })}
snapshotId="snap-1"
returnPath="/repositories/repo-1/snap-1"
queryBasePath="/mnt/project/subdir"
displayBasePath="/mnt"
/>,
);

const row = await screen.findByRole("button", { name: "project" });
await userEvent.click(within(row).getByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" }));

await waitFor(() => {
expect(restoreRequestBody).toEqual({
snapshotId: "snap-1",
include: ["/mnt/project"],
selectedItemKind: "dir",
overwrite: "always",
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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";

type SnapshotFilesRequest = {
shortId: string;
snapshotId: string;
path: string | null;
offset: string | null;
limit: string | null;
};

const snapshotFiles = {
files: [
{ name: "project", path: "/mnt/project", type: "dir" },
{ name: "a.txt", path: "/mnt/project/a.txt", type: "file" },
],
};

import { SnapshotTreeBrowser } from "../snapshot-tree-browser";

const mockListSnapshotFiles = (response = snapshotFiles) => {
const requests: SnapshotFilesRequest[] = [];

server.use(
http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ params, request }) => {
const url = new URL(request.url);
requests.push({
shortId: String(params.shortId),
snapshotId: String(params.snapshotId),
path: url.searchParams.get("path"),
offset: url.searchParams.get("offset"),
limit: url.searchParams.get("limit"),
});

return HttpResponse.json(response);
}),
);

return requests;
};

const renderSnapshotTreeBrowser = (props: Partial<ComponentProps<typeof SnapshotTreeBrowser>> = {}) => {
return render(
<SnapshotTreeBrowser
repositoryId="repo-1"
snapshotId="snap-1"
queryBasePath="/mnt/project"
displayBasePath="/mnt"
{...props}
/>,
);
};

afterEach(() => {
cleanup();
});

describe("SnapshotTreeBrowser", () => {
test("renders the query root folder when display base path is broader than query base path", async () => {
mockListSnapshotFiles();

renderSnapshotTreeBrowser();

expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});

test("renders ancestor folders when the query root is nested multiple levels below the display root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});

renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
});

expect(await screen.findByRole("button", { name: "project" })).toBeTruthy();
});

test("renders 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" }],
});

renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/report.txt",
displayBasePath: undefined,
});

expect(await screen.findByRole("button", { name: "report.txt" })).toBeTruthy();
expect(requests[0]).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project/report.txt",
offset: null,
limit: null,
});
});

test("returns the ancestor folder path when selecting above the query root", async () => {
mockListSnapshotFiles({
files: [
{ name: "subdir", path: "/mnt/project/subdir", type: "dir" },
{ name: "a.txt", path: "/mnt/project/subdir/a.txt", type: "file" },
],
});

let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;

renderSnapshotTreeBrowser({
queryBasePath: "/mnt/project/subdir",
displayBasePath: "/mnt",
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});

const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");

await userEvent.click(checkbox);

expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});

test("shows selected folder state when full paths are provided from the parent", async () => {
mockListSnapshotFiles();

renderSnapshotTreeBrowser({
withCheckboxes: true,
selectedPaths: new Set(["/mnt/project"]),
onSelectionChange: () => {},
});

const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");

expect(checkbox.getAttribute("aria-checked")).toBe("true");
});

test("returns the full snapshot path and kind when selecting a displayed folder", async () => {
mockListSnapshotFiles();

let selectedPaths: Set<string> | undefined;
let selectedKind: "file" | "dir" | null = null;

renderSnapshotTreeBrowser({
withCheckboxes: true,
onSelectionChange: (paths) => {
selectedPaths = paths;
},
onSingleSelectionKindChange: (kind) => {
selectedKind = kind;
},
});

const row = await screen.findByRole("button", { name: "project" });
const checkbox = within(row).getByRole("checkbox");

await userEvent.click(checkbox);

expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]);
expect(selectedKind === "dir").toBe(true);
});

test("uses the query base path for the initial request when display base path is broader", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

await waitFor(() => {
expect(requests[0]).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: null,
limit: null,
});
});
});

test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;

await userEvent.hover(row);

await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});

expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});

test("expands using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();

renderSnapshotTreeBrowser();

const row = await screen.findByRole("button", { name: "project" });
const expandIcon = row.querySelector("svg");
if (!expandIcon) {
throw new Error("Expected expand icon for folder row");
}

const initialRequestCount = requests.length;
await userEvent.click(expandIcon);

await waitFor(() => {
expect(requests.length).toBeGreaterThan(initialRequestCount);
});

expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
});
Loading
Loading