generated from nhcarrigan/template
bf411adeb7
## Summary This PR resolves several critical bugs that were blocking the permission modal and causing config loss: - **Permission modal not appearing** - Fixed z-index issues and runtime errors - **Config store race condition** - Resolved critical race condition causing settings to be lost - **Excessive logging** - Removed redundant fmt layer that was writing to hidden stdout - **System tool prompts** - Prevented unnecessary permission prompts for built-in tools - **Permission batching** - Added support for parallel permission requests - **ExitPlanMode tool** - Fixed ExitPlanMode tool not functioning correctly ## Changes Made ### Permission Modal Fixes - Updated z-index to proper value (9999) to ensure modal appears above all other UI elements - Fixed runtime errors that were preventing modal from rendering - Resolved issues with permission grants not being properly applied ### Config Store Race Condition - Fixed critical race condition where multiple rapid config updates would result in lost settings - Ensured config writes are properly sequenced to prevent data loss - Added proper synchronisation for config store operations ### Logging Cleanup - Removed redundant fmt formatting layer that was outputting to hidden stdout - Cleaned up excessive debug logging added during troubleshooting - Removed temporary debugging documentation files ### UX Improvements - Added close confirmation modal with minimise to tray option - Implemented batching for parallel permission requests - Added debug console for viewing frontend and backend logs ### ExitPlanMode Fix - Fixed ExitPlanMode tool not functioning correctly, ensuring proper transitions out of plan mode ## Issues Resolved Closes #112 - Permission flow now properly handles multiple tool requests Closes #113 - ExitPlanMode tool now functions correctly Closes #126 - Debug console feature added (partial - basic implementation complete) ## Test Plan - [x] Permission modal appears and functions correctly - [x] Config settings persist across app restarts - [x] No excessive logging in production builds - [x] System tools don't trigger permission prompts - [x] Parallel permission requests are properly batched - [x] Debug console displays frontend and backend logs - [x] ExitPlanMode properly exits plan mode --- ✨ This PR was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #127 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { get } from "svelte/store";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
|
import { snippetsStore, type Snippet } from "./snippets";
|
|
|
|
describe("Snippet interface", () => {
|
|
it("defines all required fields", () => {
|
|
const snippet: Snippet = {
|
|
id: "snippet-123",
|
|
name: "Git Status",
|
|
content: "git status",
|
|
category: "Git",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
};
|
|
|
|
expect(snippet.id).toBe("snippet-123");
|
|
expect(snippet.name).toBe("Git Status");
|
|
expect(snippet.content).toBe("git status");
|
|
expect(snippet.category).toBe("Git");
|
|
expect(snippet.is_default).toBe(false);
|
|
expect(snippet.created_at).toBe("2024-01-01T00:00:00Z");
|
|
expect(snippet.updated_at).toBe("2024-01-01T00:00:00Z");
|
|
});
|
|
|
|
it("supports default snippets", () => {
|
|
const defaultSnippet: Snippet = {
|
|
id: "default-git-status",
|
|
name: "Git Status",
|
|
content: "git status --short",
|
|
category: "Git",
|
|
is_default: true,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
};
|
|
|
|
expect(defaultSnippet.is_default).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("snippetsStore", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("store structure", () => {
|
|
it("has all expected methods", () => {
|
|
expect(typeof snippetsStore.loadSnippets).toBe("function");
|
|
expect(typeof snippetsStore.saveSnippet).toBe("function");
|
|
expect(typeof snippetsStore.createSnippet).toBe("function");
|
|
expect(typeof snippetsStore.updateSnippet).toBe("function");
|
|
expect(typeof snippetsStore.deleteSnippet).toBe("function");
|
|
expect(typeof snippetsStore.resetDefaults).toBe("function");
|
|
expect(typeof snippetsStore.setSelectedCategory).toBe("function");
|
|
});
|
|
|
|
it("has subscribable stores", () => {
|
|
expect(typeof snippetsStore.snippets.subscribe).toBe("function");
|
|
expect(typeof snippetsStore.categories.subscribe).toBe("function");
|
|
expect(typeof snippetsStore.filteredSnippets.subscribe).toBe("function");
|
|
expect(typeof snippetsStore.isLoading.subscribe).toBe("function");
|
|
expect(typeof snippetsStore.selectedCategory.subscribe).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("loadSnippets", () => {
|
|
it("loads snippets and categories from backend", async () => {
|
|
const mockSnippets: Snippet[] = [
|
|
{
|
|
id: "snippet-1",
|
|
name: "Snippet 1",
|
|
content: "content 1",
|
|
category: "Git",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
},
|
|
];
|
|
const mockCategories = ["Git", "Shell", "Docker"];
|
|
|
|
setMockInvokeResult("list_snippets", mockSnippets);
|
|
setMockInvokeResult("get_snippet_categories", mockCategories);
|
|
|
|
await snippetsStore.loadSnippets();
|
|
|
|
expect(invoke).toHaveBeenCalledWith("list_snippets");
|
|
expect(invoke).toHaveBeenCalledWith("get_snippet_categories");
|
|
});
|
|
|
|
it("handles load errors gracefully", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("list_snippets", new Error("Failed to load"));
|
|
|
|
await snippetsStore.loadSnippets();
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to load snippets:", expect.any(Error));
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("saveSnippet", () => {
|
|
it("saves snippet and reloads list", async () => {
|
|
const snippet: Snippet = {
|
|
id: "snippet-123",
|
|
name: "Test",
|
|
content: "test content",
|
|
category: "Test",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
};
|
|
|
|
setMockInvokeResult("save_snippet", undefined);
|
|
setMockInvokeResult("list_snippets", [snippet]);
|
|
setMockInvokeResult("get_snippet_categories", ["Test"]);
|
|
|
|
const result = await snippetsStore.saveSnippet(snippet);
|
|
|
|
expect(result).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith("save_snippet", { snippet });
|
|
});
|
|
|
|
it("handles save errors gracefully", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("save_snippet", new Error("Failed to save"));
|
|
|
|
const snippet: Snippet = {
|
|
id: "snippet-123",
|
|
name: "Test",
|
|
content: "test content",
|
|
category: "Test",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
};
|
|
|
|
const result = await snippetsStore.saveSnippet(snippet);
|
|
|
|
expect(result).toBe(false);
|
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to save snippet:", expect.any(Error));
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("createSnippet", () => {
|
|
it("creates new snippet with generated ID and timestamps", async () => {
|
|
setMockInvokeResult("save_snippet", undefined);
|
|
setMockInvokeResult("list_snippets", []);
|
|
setMockInvokeResult("get_snippet_categories", ["Shell"]);
|
|
|
|
const result = await snippetsStore.createSnippet("My Snippet", "echo hello", "Shell");
|
|
|
|
expect(result).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
"save_snippet",
|
|
expect.objectContaining({
|
|
snippet: expect.objectContaining({
|
|
name: "My Snippet",
|
|
content: "echo hello",
|
|
category: "Shell",
|
|
is_default: false,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("updateSnippet", () => {
|
|
it("updates existing snippet preserving created_at", async () => {
|
|
const existingSnippet: Snippet = {
|
|
id: "snippet-123",
|
|
name: "Old Name",
|
|
content: "old content",
|
|
category: "Old Category",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
};
|
|
|
|
setMockInvokeResult("list_snippets", [existingSnippet]);
|
|
setMockInvokeResult("save_snippet", undefined);
|
|
setMockInvokeResult("get_snippet_categories", ["New Category"]);
|
|
|
|
const result = await snippetsStore.updateSnippet(
|
|
"snippet-123",
|
|
"New Name",
|
|
"new content",
|
|
"New Category"
|
|
);
|
|
|
|
expect(result).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
"save_snippet",
|
|
expect.objectContaining({
|
|
snippet: expect.objectContaining({
|
|
id: "snippet-123",
|
|
name: "New Name",
|
|
content: "new content",
|
|
category: "New Category",
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("returns false for non-existent snippet", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("list_snippets", []);
|
|
|
|
const result = await snippetsStore.updateSnippet(
|
|
"non-existent",
|
|
"Name",
|
|
"content",
|
|
"Category"
|
|
);
|
|
|
|
expect(result).toBe(false);
|
|
expect(consoleSpy).toHaveBeenCalledWith("Snippet not found for update");
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("deleteSnippet", () => {
|
|
it("deletes snippet by ID", async () => {
|
|
setMockInvokeResult("delete_snippet", undefined);
|
|
setMockInvokeResult("list_snippets", []);
|
|
setMockInvokeResult("get_snippet_categories", []);
|
|
|
|
const result = await snippetsStore.deleteSnippet("snippet-123");
|
|
|
|
expect(result).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith("delete_snippet", { snippetId: "snippet-123" });
|
|
});
|
|
|
|
it("handles delete errors gracefully", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("delete_snippet", new Error("Cannot delete default snippet"));
|
|
|
|
const result = await snippetsStore.deleteSnippet("default-1");
|
|
|
|
expect(result).toBe(false);
|
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete snippet:", expect.any(Error));
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("resetDefaults", () => {
|
|
it("resets default snippets", async () => {
|
|
setMockInvokeResult("reset_default_snippets", undefined);
|
|
setMockInvokeResult("list_snippets", []);
|
|
setMockInvokeResult("get_snippet_categories", []);
|
|
|
|
const result = await snippetsStore.resetDefaults();
|
|
|
|
expect(result).toBe(true);
|
|
expect(invoke).toHaveBeenCalledWith("reset_default_snippets");
|
|
});
|
|
|
|
it("handles reset errors gracefully", async () => {
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("reset_default_snippets", new Error("Reset failed"));
|
|
|
|
const result = await snippetsStore.resetDefaults();
|
|
|
|
expect(result).toBe(false);
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
"Failed to reset default snippets:",
|
|
expect.any(Error)
|
|
);
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("setSelectedCategory", () => {
|
|
it("updates selected category", () => {
|
|
snippetsStore.setSelectedCategory("Git");
|
|
expect(get(snippetsStore.selectedCategory)).toBe("Git");
|
|
});
|
|
|
|
it("can be cleared with null", () => {
|
|
snippetsStore.setSelectedCategory("Git");
|
|
snippetsStore.setSelectedCategory(null);
|
|
expect(get(snippetsStore.selectedCategory)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("filteredSnippets", () => {
|
|
it("returns all snippets when no category selected", async () => {
|
|
const mockSnippets: Snippet[] = [
|
|
{
|
|
id: "snippet-1",
|
|
name: "Git Status",
|
|
content: "git status",
|
|
category: "Git",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
id: "snippet-2",
|
|
name: "Docker PS",
|
|
content: "docker ps",
|
|
category: "Docker",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
},
|
|
];
|
|
|
|
setMockInvokeResult("list_snippets", mockSnippets);
|
|
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
|
|
|
await snippetsStore.loadSnippets();
|
|
snippetsStore.setSelectedCategory(null);
|
|
|
|
const filtered = get(snippetsStore.filteredSnippets);
|
|
expect(filtered).toHaveLength(2);
|
|
});
|
|
|
|
it("filters snippets by selected category", async () => {
|
|
const mockSnippets: Snippet[] = [
|
|
{
|
|
id: "snippet-1",
|
|
name: "Git Status",
|
|
content: "git status",
|
|
category: "Git",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
},
|
|
{
|
|
id: "snippet-2",
|
|
name: "Docker PS",
|
|
content: "docker ps",
|
|
category: "Docker",
|
|
is_default: false,
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
},
|
|
];
|
|
|
|
setMockInvokeResult("list_snippets", mockSnippets);
|
|
setMockInvokeResult("get_snippet_categories", ["Git", "Docker"]);
|
|
|
|
await snippetsStore.loadSnippets();
|
|
snippetsStore.setSelectedCategory("Git");
|
|
|
|
const filtered = get(snippetsStore.filteredSnippets);
|
|
expect(filtered).toHaveLength(1);
|
|
expect(filtered[0].category).toBe("Git");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("snippet ID generation", () => {
|
|
it("generates unique custom snippet IDs", () => {
|
|
const generateId = () => `custom-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
const id1 = generateId();
|
|
const id2 = generateId();
|
|
|
|
expect(id1).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
|
expect(id2).toMatch(/^custom-\d+-[a-z0-9]+$/);
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
});
|
|
|
|
describe("snippet validation", () => {
|
|
it("requires non-empty name", () => {
|
|
const snippet = { name: "" };
|
|
const isValid = snippet.name.trim().length > 0;
|
|
expect(isValid).toBe(false);
|
|
});
|
|
|
|
it("requires non-empty content", () => {
|
|
const snippet = { content: " " };
|
|
const isValid = snippet.content.trim().length > 0;
|
|
expect(isValid).toBe(false);
|
|
});
|
|
|
|
it("requires non-empty category", () => {
|
|
const snippet = { category: "Git" };
|
|
const isValid = snippet.category.trim().length > 0;
|
|
expect(isValid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("snippet content types", () => {
|
|
it("supports multiline content", () => {
|
|
const snippet = {
|
|
content: `git add .
|
|
git commit -m "message"
|
|
git push`,
|
|
};
|
|
|
|
expect(snippet.content.includes("\n")).toBe(true);
|
|
expect(snippet.content.split("\n")).toHaveLength(3);
|
|
});
|
|
|
|
it("supports content with special characters", () => {
|
|
const snippet = {
|
|
content: "echo \"Hello, World!\" && echo 'Single quotes'",
|
|
};
|
|
|
|
expect(snippet.content).toContain('"');
|
|
expect(snippet.content).toContain("'");
|
|
expect(snippet.content).toContain("&&");
|
|
});
|
|
|
|
it("supports content with variables", () => {
|
|
const snippet = {
|
|
content: "docker run -v $PWD:/app ${IMAGE_NAME}:${TAG}",
|
|
};
|
|
|
|
expect(snippet.content).toContain("$PWD");
|
|
expect(snippet.content).toContain("${IMAGE_NAME}");
|
|
});
|
|
});
|