import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { QuickEntryBox } from "../components/QuickEntryBox"; import type { Task } from "@fusion/core"; import { fetchSettings, fetchAgents } from "../api"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; // Minimal task list for deps const mockTasks: Task[] = [ { id: "FN-001", title: "Test task 1", description: "First test task", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", }, ]; // Mock the api module vi.mock("../api", () => ({ fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [], }), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 30000, groupOverlappingFiles: true, autoMerge: true, }), refineText: vi.fn(), getRefineErrorMessage: vi.fn((err: unknown) => (err as Error)?.message || "Failed"), fetchAgents: vi.fn().mockResolvedValue([]), uploadAttachment: vi.fn().mockResolvedValue({}), updateGlobalSettings: vi.fn().mockResolvedValue({}), })); // Mock lucide-react icons vi.mock("lucide-react", () => ({ Link: () => null, Paperclip: () => null, Brain: () => null, Lightbulb: () => null, ListTree: () => null, Sparkles: () => null, Save: () => null, X: () => null, ChevronDown: () => null, ChevronUp: () => null, ChevronRight: () => null, Bot: () => null, Maximize2: () => null, Minimize2: () => null, })); vi.mock("../components/ModelSelectionModal", () => ({ ModelSelectionModal: () => null, })); vi.mock("../components/CustomModelDropdown", () => ({ CustomModelDropdown: ({ value, onChange, label, }: { value: string; onChange: (value: string) => void; label: string; }) =>
{value || "none"}
, })); function renderQuickEntryBox(props = {}) { const defaultProps = { onCreate: vi.fn().mockResolvedValue(undefined), addToast: vi.fn(), tasks: mockTasks, projectId: "test-proj", }; return render(); } function expandQuickEntry() { const toggleButton = screen.getByTestId("quick-entry-toggle"); fireEvent.click(toggleButton); } function mockDesktopViewport() { Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true }); return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); } describe("quick-entry-expanded-height CSS contract (FN-1631)", () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers({ shouldAdvanceTime: true }); localStorage.clear(); vi.mocked(fetchAgents).mockResolvedValue([]); Object.defineProperty(URL, "createObjectURL", { configurable: true, writable: true, value: vi.fn(() => "blob:mock"), }); Object.defineProperty(URL, "revokeObjectURL", { configurable: true, writable: true, value: vi.fn(), }); }); afterEach(async () => { await act(async () => { vi.runOnlyPendingTimers(); }); vi.useRealTimers(); localStorage.clear(); }); /** * CSS Contract Test: * When the quick-entry textarea is expanded (has class `quick-entry-input--expanded`), * its min-height should be GREATER than when collapsed. * * This test guards against CSS specificity regressions where container-state selectors * (`.quick-entry-box--expanded .quick-entry-input`) inadvertently override the * expanded-height rules set by `.quick-entry-input--expanded`. */ it("expanded class applies when quick-entry is expanded via toggle", () => { mockDesktopViewport(); renderQuickEntryBox(); const textarea = screen.getByTestId("quick-entry-input"); const box = screen.getByTestId("quick-entry-box"); // Initially collapsed expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true); expect(box.classList.contains("quick-entry-box--expanded")).toBe(false); expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false); // Expand via toggle expandQuickEntry(); // Verify expanded state expect(box.classList.contains("quick-entry-box--expanded")).toBe(true); expect(box.classList.contains("quick-entry-box--collapsed")).toBe(false); expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); }); /** * Guards against the specific CSS specificity bug where * `.quick-entry-box--expanded .quick-entry-input` (higher specificity) * overrides `.quick-entry-input--expanded { min-height: 80px; }` * * We verify the CSS selectors have correct specificity by checking the stylesheet. */ it("CSS uses :not() to prevent container selectors from overriding expanded min-height", () => { // Read the CSS file const cssPath = resolve(__dirname, "../styles.css"); const cssContent = readFileSync(cssPath, "utf-8"); // Find the quick-entry-box--expanded rule const expandedRuleMatch = cssContent.match( /\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/, ); // Find the quick-entry-input--expanded rule const inputExpandedRuleMatch = cssContent.match( /\.quick-entry-input--expanded\s*\{[^}]*min-height:\s*(\d+)px/, ); // Both selectors should exist expect(expandedRuleMatch).not.toBeNull(); expect(inputExpandedRuleMatch).not.toBeNull(); if (expandedRuleMatch && inputExpandedRuleMatch) { const containerMinHeight = parseInt(expandedRuleMatch[1], 10); const expandedMinHeight = parseInt(inputExpandedRuleMatch[1], 10); // The expanded input min-height (80px) should be greater than container min-height (36px) expect(expandedMinHeight).toBeGreaterThan(containerMinHeight); expect(expandedMinHeight).toBe(80); expect(containerMinHeight).toBe(36); } }); /** * Verify the CSS uses :not() to prevent container override when input is expanded. * This ensures the fix is using the correct approach. */ it("CSS container selectors use :not(.quick-entry-input--expanded) pattern", () => { const cssPath = resolve(__dirname, "../styles.css"); const cssContent = readFileSync(cssPath, "utf-8"); // Check that .quick-entry-box--expanded uses :not() to avoid overriding expanded input expect(cssContent).toMatch( /\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)/, ); // Check that .quick-entry-box--collapsed also uses :not() for consistency expect(cssContent).toMatch( /\.quick-entry-box--collapsed\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)/, ); // Check that .quick-entry-input--expanded still sets 80px min-height expect(cssContent).toMatch(/\.quick-entry-input--expanded\s*\{[^}]*min-height:\s*80px/); }); /** * Regression test: Verify focus-triggered expansion also applies the expanded class */ it("focus-triggered expansion applies expanded class", () => { mockDesktopViewport(); renderQuickEntryBox({ autoExpand: true }); const textarea = screen.getByTestId("quick-entry-input"); // Focus triggers auto-expand fireEvent.focus(textarea); // Expanded class should be applied expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true); }); /** * Regression test: Verify collapsed state maintains correct classes */ it("collapsed state has correct classes (32px equivalent)", () => { mockDesktopViewport(); renderQuickEntryBox(); const textarea = screen.getByTestId("quick-entry-input"); const box = screen.getByTestId("quick-entry-box"); // Collapsed state expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true); expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false); // Verify the CSS has collapsed min-height of 32px const cssPath = resolve(__dirname, "../styles.css"); const cssContent = readFileSync(cssPath, "utf-8"); const collapsedRuleMatch = cssContent.match( /\.quick-entry-box--collapsed\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/, ); expect(collapsedRuleMatch).not.toBeNull(); if (collapsedRuleMatch) { const collapsedMinHeight = parseInt(collapsedRuleMatch[1], 10); expect(collapsedMinHeight).toBe(32); } }); /** * Integration test: When box is expanded but input is not expanded, * container-level min-height (36px) should apply. * * Note: The toggle button always expands both box and input together. * The only way to have box expanded but input not expanded is via blur * when autoCollapse behavior is triggered. Since autoCollapse is not * implemented, this test verifies the CSS has the correct values for * this case if it ever becomes possible. */ it("CSS has correct 36px container min-height for box expanded but input not expanded", () => { // This test verifies the CSS file has the correct container min-height (36px) // for the case where the box is expanded but the input is not. // In practice, the toggle always expands both, but the CSS still needs // to handle this case correctly. const cssPath = resolve(__dirname, "../styles.css"); const cssContent = readFileSync(cssPath, "utf-8"); // Check that the expanded box without expanded input uses 36px const expandedNonExpandedMatch = cssContent.match( /\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/, ); expect(expandedNonExpandedMatch).not.toBeNull(); if (expandedNonExpandedMatch) { const minHeight = parseInt(expandedNonExpandedMatch[1], 10); expect(minHeight).toBe(36); } }); });