* 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.
245 lines
8.5 KiB
TypeScript
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();
|
|
});
|
|
}); |