Files
gridline/src/components/db-viewer/ObjectExplorerPage.test.tsx
T
adrianbonpin 04e8ed3300 v0.7.5: bundled PG tools, schema CRUD, object search, copy-as-DDL, object dependencies (#11)
* chore: bump version to 0.7.5 (Task 1.1)

* feat(db): pure object DDL/search/depend builders (Task 1.2)

* feat(models): ObjectSearchHit, DependencyInfo, PgToolPaths, tool source fields (Task 1.3)

* feat(backup): bundle-aware pg tool resolution, system-first fallback (Task 2.1)

* feat(objects): schema CRUD commands + integration test (Task 2.2)

* feat(objects): search_objects command (current-schema, all types) (Task 2.3)

* feat(objects): get_object_ddl for all browsable types (Task 2.4)

* feat(objects): pg_depend object dependencies + schema contents (Task 2.5)

* feat(ipc): register object management commands (Task 3.1)

* feat(ipc): frontend wrappers + types for object management (Task 3.2)

* feat(build): declare bundled pg_tools resources (Task 3.3)

* test(capabilities): lock objects capability PG-only for search/ddl/dependencies (Task 3.4)

* feat(ui): Cmd+K object search palette in DB viewer (Task 4.1)

* feat(ui): schema CRUD menu + dependency dialog (Task 4.2)

* feat(ui): copy-as-DDL + dependency view context menu in Objects (Task 4.3)

* feat(ui): dependency check before table drop + bundled-tool status (Task 4.4)

* docs: v0.7.5 release notes + status table + bundled-tools (Task 5.1)

* ci: build/verify bundled pg client tools per platform before tauri build (Task 5.2)

* fix(objects): report pg_rewrite dependencies as the dependent view (pg_class)

pg_depend records view dependencies via the view's internal rewrite rule
(classid = pg_rewrite). The user-facing dependent object is the VIEW itself,
so map that classid to pg_class (name still resolved through ev_class).
Fixes the live-PG integration test object_dependencies_for_table_includes_view
and makes the dependency dialog readable for view dependents.

* test: add idempotent PG integration-test seed script

Seeds the first PG test db (GRIDLINE_TEST_SRC) with the objects the
#[ignore] integration tests assert against: users (+users_id_seq),
products (3 rows), orders (3 rows), order_summary view, audit_log +
get_user functions, user_role enum. Idempotent: DROP + recreate.

* fix(build): regenerate multi-resolution icons + roadmap Keychain item

- icon.ico previously contained a single 16x16 frame (Windows scaled it
  up -> blurry taskbar/start-menu icon). Regenerated from the 512px source
  via 'tauri icon': ICO now has 16/24/32/48/64/256 frames, icns has full
  @2x coverage up to 1024px, PNGs re-rendered from the same source.
- Added icons/icon.png (512px) to bundle.icon so Linux hicolor installs
  a high-DPI entry.
- ROADMAP: log the 'Enable Keychain' toggle (currently a form-only
  placeholder) under Next up -> Connection & credentials.

* docs(readme): dynamic version-free download links + 3-col table

- release.yml: set releaseAssetNamePattern to '[name]_[platform]_[arch][setup][ext]'
  (version-free), so asset filenames are stable across releases:
  Gridline_darwin_aarch64.dmg, Gridline_windows_x64-setup.exe,
  Gridline_linux_amd64.deb, Gridline_linux_x86_64.rpm, etc.
- README: both download tables now 3-column (OS | Architecture | Download)
  and link via GitHub's releases/latest/download/<file> redirect — they
  always point at the newest published release, no per-release edits.
- AGENTS.md: releases checklist updated — download links stay version-free.

* docs(readme): platform-per-column download table (macOS | Windows | Linux)

Table now mirrors the release layout: one column per OS with a logo row
and a download-links row underneath. Links stay version-free via
releases/latest/download/<file> (releaseAssetNamePattern in release.yml).

* docs(readme): revert download table to clean image-less 3-col layout

Logo row was hard to read on GitHub dark mode; the plain OS | Architecture
| Download table is cleaner and still uses dynamic releases/latest links.
2026-08-05 20:46:36 +08:00

245 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as commands from "../../lib/commands";
describe("ObjectExplorerPage", () => {
beforeEach(() => {
vi.restoreAllMocks();
useDbViewerStore.getState().reset();
useDbViewerStore.setState({
schemas: ["public"],
currentSchema: "public",
});
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
});
it("renders functions by default", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() =>
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
);
});
it("functions type filters to kind === 'f'", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
});
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
const user = userEvent.setup();
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
{
name: "idx_users_email",
schema: "public",
table: "users",
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
is_unique: true,
method: "btree",
columns: ["email"],
size_bytes: 8192,
tablespace: null,
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
);
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("idx_users_email"));
await waitFor(() => {
expect(screen.getByText("Index")).toBeInTheDocument();
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("8192")).toBeInTheDocument();
});
});
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
const user = userEvent.setup();
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
{
name: "chk_users_positive",
schema: "public",
table: "users",
contype: "CHECK",
definition: "CHECK (age > 0)",
deferrable: false,
validated: true,
columns: ["age"],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Constraints"));
await waitFor(() =>
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
);
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
await user.click(screen.getByText("chk_users_positive"));
await waitFor(() => {
expect(screen.getByText("Constraint")).toBeInTheDocument();
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Deferrable")).toBeInTheDocument();
});
});
it("shows 'No indexes found' when getIndexes returns []", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
);
});
it("shows an error message when getIndexes rejects", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Indexes"));
await waitFor(() =>
expect(screen.getByText("boom")).toBeInTheDocument(),
);
});
it("procedures type filters getFunctions to kind === 'p'", async () => {
const user = userEvent.setup();
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "do_thing",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN END",
kind: "p",
},
{
name: "calc",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "sql",
source: "SELECT 1",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Procedures"));
await waitFor(() =>
expect(screen.getByText("do_thing")).toBeInTheDocument(),
);
expect(screen.queryByText("calc")).not.toBeInTheDocument();
});
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["a"] },
]);
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
const writeText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
fireEvent.click(screen.getByText(/copy ddl/i));
await waitFor(() =>
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
);
expect(writeText).toHaveBeenCalledWith("CREATE TYPE ...");
});
it("View dependencies opens DependencyDialog", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add_one",
schema: "public",
return_type: "int",
argument_types: ["int"],
argument_names: ["x"],
argument_modes: ["IN"],
language: "sql",
source: "SELECT $1 + 1",
kind: "f",
},
]);
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([
{ deptype: "n", class: "pg_class", name: "v" },
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => screen.getByText("add_one(int)"));
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
fireEvent.click(screen.getByText(/dependencies/i));
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
});
it("preselects type from store on mount", () => {
useDbViewerStore.setState({ selectedObjectType: "sequences" });
render(<ObjectExplorerPage connectionId="c1" />);
expect(screen.getByText("Sequences")).toBeTruthy();
});
});