feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages
- EditorModal loads once per path (a parent re-render no longer reloads the file over the user's typing), refuses Save after a failed load, opens a truncated (>256 KB) file read-only, and asks before overwriting a file that changed on disk or could not be re-read. - Settings/Hooks editors unset only keys from the snapshot taken at Edit, warn when the file changed underneath, keep a renamed key's value, and refuse a new key named like a redacted one (env) or a read-only hook event, which used to replace the secret/hooks. - Project scope shows its resolved folder and is disabled inside the plugin cache. Account-profile, pricing-row and pet deletes confirm; two-click actions disarm after 3 s; unsaved drafts ask before closing. - Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and scrolls to + highlights the row; the active config tab is in the URL. - General splits into General / Data / About; Pricing moves to a Cost group with one shared load; config tabs group as Content / Extensions / Config with arrow-key roving; Promise.allSettled per tab. - Data: browser-preferences export/import (allowlisted keys), per-section reset, housekeeping dry-run first, clear-all behind a typed phrase. Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and dry_run returns exactly what the real run would delete. - Alerts can be edited; webhooks/alerts/remote-source failures toast; copies go through copyText; modals get dialog roles and useDialog. - Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of row controls, arrow keys move it, reduced motion honoured; the dead /run launch link goes to the workspace.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file AlertsNotifications.test.tsx
|
||||
* @description Alert rules are editable through the create form (PATCH, type
|
||||
* fixed), and an invalid cooldown blocks saving instead of silently becoming 300.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const update = vi.fn(async () => ({ rule: {} }));
|
||||
const create = vi.fn(async () => ({ rule: {} }));
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
rules: {
|
||||
list: vi.fn(async () => ({
|
||||
rules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Idle",
|
||||
rule_type: "inactivity",
|
||||
config: { minutes: 30 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 60,
|
||||
},
|
||||
],
|
||||
})),
|
||||
update: (...a: unknown[]) => update(...(a as [])),
|
||||
create: (...a: unknown[]) => create(...(a as [])),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
list: vi.fn(async () => ({ alerts: [], total: 0, unacked: 0 })),
|
||||
ack: vi.fn(),
|
||||
ackAll: vi.fn(),
|
||||
},
|
||||
webhooks: {
|
||||
list: vi.fn(async () => ({ targets: [] })),
|
||||
providers: vi.fn(async () => ({ providers: [] })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
const renderIt = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("AlertsNotifications rules", () => {
|
||||
it("edits a rule in place and validates the cooldown", async () => {
|
||||
renderIt();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /edit rule/i }));
|
||||
const name = screen.getByDisplayValue("Idle");
|
||||
const cooldown = screen.getByDisplayValue("60");
|
||||
await userEvent.clear(cooldown);
|
||||
await userEvent.type(cooldown, "-5");
|
||||
const save = screen.getByRole("button", { name: /save rule/i });
|
||||
expect(save).toBeDisabled();
|
||||
await userEvent.clear(cooldown);
|
||||
await userEvent.type(cooldown, "120");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Idle long");
|
||||
await userEvent.click(save);
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith("r1", {
|
||||
name: "Idle long",
|
||||
config: { minutes: 30 },
|
||||
cooldown_seconds: 120,
|
||||
})
|
||||
);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
}, 15_000); // many typed fields: slow under a parallel run
|
||||
});
|
||||
@@ -221,3 +221,28 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — closing vs skipping", () => {
|
||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||
const { unmount } = render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:skipVersion")));
|
||||
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file WebhookSettings.test.tsx
|
||||
* @description The webhook form must not save "only selected rules" with no
|
||||
* rule ticked (the server reads an empty list as ALL rules), and a failed
|
||||
* toggle must surface as an error toast instead of only a console line.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const toastError = vi.fn();
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { error: (m: unknown) => toastError(m), success: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const update = vi.fn();
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
webhooks: {
|
||||
list: vi.fn(async () => ({
|
||||
targets: [
|
||||
{
|
||||
id: "w1",
|
||||
name: "Team",
|
||||
type: "generic",
|
||||
url_preview: "https://x/…",
|
||||
enabled: true,
|
||||
rule_ids: [],
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
})),
|
||||
providers: vi.fn(async () => ({
|
||||
providers: [
|
||||
{
|
||||
type: "generic",
|
||||
label: "Generic",
|
||||
url_required: true,
|
||||
has_default_url: false,
|
||||
supports_secret: false,
|
||||
supports_headers: false,
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
})),
|
||||
update: (...a: unknown[]) => update(...a),
|
||||
create: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
test: vi.fn(),
|
||||
},
|
||||
alerts: { rules: { list: vi.fn(async () => ({ rules: [{ id: "r1", name: "Rule one" }] })) } },
|
||||
},
|
||||
}));
|
||||
|
||||
import { WebhookSettings } from "../WebhookSettings";
|
||||
|
||||
beforeEach(() => {
|
||||
toastError.mockReset();
|
||||
update.mockReset();
|
||||
});
|
||||
|
||||
describe("WebhookSettings", () => {
|
||||
it("blocks 'only selected rules' with none ticked", async () => {
|
||||
render(<WebhookSettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /add/i }));
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
await userEvent.type(inputs[0]!, "Hook");
|
||||
await userEvent.type(inputs[1]!, "https://example.com/h");
|
||||
const create = screen.getByRole("button", { name: /^create/i });
|
||||
expect(create).toBeEnabled();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /all/i }));
|
||||
expect(create).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Rule one" }));
|
||||
expect(create).toBeEnabled();
|
||||
});
|
||||
|
||||
it("toasts a failed toggle", async () => {
|
||||
update.mockRejectedValue(new Error("boom"));
|
||||
render(<WebhookSettings />);
|
||||
await userEvent.click(await screen.findByRole("switch"));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user